diff --git a/.claude/skills/naksha-test/SKILL.md b/.claude/skills/naksha-test/SKILL.md new file mode 100644 index 0000000000..4ba73af23a --- /dev/null +++ b/.claude/skills/naksha-test/SKILL.md @@ -0,0 +1,70 @@ +--- +name: naksha-test +description: Use ONLY when asked to run tests for the Naksha project. Do NOT use for other projects or general testing questions. +--- + +# General +Most tests require a database. If the tests are executed without environment variables, they will start docker containers. When unclear, ask the user if they want to run the tests using automatically created Docker containers, or if they prefer to run the tests against their own, possibly local, PostgresQL test database. + +# Environment Variables +All environment variables contain some placeholders that need to be replaced: + +- `{host}`: The host of the PostgresQL cluster. If not given any other instructions, assume `localhost`. +- `{port}`: Needs to be replaced by you with the port at which the database is listening. If not given any other instructions, assume `5432`. +- `{user}`: Needs to be replaced by you with the user. If not given any other instructions, assume `postgres`. +- `{password}`: Needs to be replaced by you with the password. If not given any other instructions, assume `password`. + +You can test the connection to the database. If you detect that the connection to the database fails due to wrong credentials or hostname, ask the user for host, port, user, and password _(whatever is needed)_. Use defaults for any value not provided. Tell the user the defaults. + +## Library tests (here-naksha-lib-psql) +Only needs one variable. If not set, Docker auto-starts: + +```bash +export NAKSHA_TEST_PSQL_DB_URL="jdbc:postgresql://{host}:{port}/postgres?user={user}&password={password}&ssl=false" +``` + +## Server tests (here-naksha-app-service) +Needs all variables. These tests require a running Naksha server and will fail without one. Skip unless explicitly asked: + +```bash +export NAKSHA_APP_SERVICE_TEST_CONTEXT=LOCAL_STANDALONE +export NAKSHA_TEST_STORAGE_ID=local_psql_test_storage +export HUB_ADMIN_STORAGE_ID=local_psql_test_storage +export NAKSHA_TEST_PSQL_DB_URL="jdbc:postgresql://{host}:{port}/postgres?user={user}&password={password}&ssl=false" +export NAKSHA_TEST_ADMIN_DB_URL="jdbc:postgresql://{host}:{port}/postgres?user={user}&password={password}&ssl=false" +export NAKSHA_TEST_DATA_DB_URL="jdbc:postgresql://{host}:{port}/postgres?user={user}&password={password}&ssl=false" +``` + +# Commands + +## All library tests (JVM): +Docker auto-starts if no env vars are set: + +```bash +./gradlew :here-naksha-lib-model:jvmTest :here-naksha-lib-psql:jvmTest :here-naksha-lib-jbon:jvmTest :here-naksha-lib-geo:jvmTest +``` + +## All JVM tests (includes server tests that will fail without a running server): +Docker auto-starts if no env vars are set. This includes `here-naksha-app-service:jvmTest` which requires a running Naksha server and will fail with `ConnectException` if no server is available: + +```bash +./gradlew jvmTest +``` + +## All library tests (JS): +```bash +./gradlew :here-naksha-lib-model:jsTest :here-naksha-lib-jbon:jsTest :here-naksha-lib-geo:jsTest +``` + +## Server tests +Only run if user explicitly asks. Requires a running Naksha server: + +```bash +./gradlew :here-naksha-app-service:jvmTest +``` + +# Common Issues +- Kotlintest discovery errors: If `here-naksha-lib-psql:jvmTest` fails with test discovery errors, try `./gradlew clean` first +- Docker not available: The psql tests require Docker. If Docker isn't running, set `NAKSHA_TEST_PSQL_DB_URL` to an external Postgres instance +- Port conflicts: The Docker container uses host port 15432. If this port is in use, the container will fail to start +- Server tests fail with ConnectException: This is expected when no Naksha server is running. Skip these tests unless the server is available diff --git a/docs/ai/architecture-overview.md b/docs/ai/architecture-overview.md new file mode 100644 index 0000000000..da5e2bca63 --- /dev/null +++ b/docs/ai/architecture-overview.md @@ -0,0 +1,206 @@ +# Architecture Overview + +## Architecture Overview + +The codebase follows a **two-layer abstraction** pattern: + +### Layer 1: `lib-model` — Storage-agnostic interfaces +- **`ISession`** → core session API with `execute(request: Request): Response` +- **`IStorage`** → storage lifecycle, session creation +- **`IWriteSession`** → extends read session with `commit()`, `rollback()`, `useTransaction()` +- **`StorageTx`** → tuple encoding/decoding, member building +- **`AbstractStorage`** → base class all storages must extend (caching, lifecycle) + +### Layer 2: `lib-psql` — PostgreSQL implementation +- **`PgSession`** → implements `ISession`, manages PG connections +- **`PgStorage`** → extends `AbstractStorage`, manages PG connection pools +- **`PgWriter`** → stateful writer, dispatches to operation-specific classes +- **`PgWriterInsert/Upsert/Update/Delete`** → SQL generation per operation + +## Code Flow: Writing a New Feature + +``` +Client Code + │ + ├── 1. Build Write instruction + │ Write().createFeature(collection, feature) + │ Write().upsertFeature(mapId, colId, feature) + │ └── Sets: mapId, collectionId, op=CREATE/UPSERT, feature + │ + ├── 2. Wrap in WriteRequest + │ WriteRequest().add(write) + │ + ├── 3. Execute on session + │ session.execute(writeRequest) + │ │ + │ ├── PgSession.execute() routes to writer + │ │ writer = PgWriter(session, useSavepoint) + │ │ writer.execute(request.writes) + │ │ │ + │ │ ├── prepareWrite() + │ │ │ ├── Resolves mapId → PgMap (from adminMap cache/DB) + │ │ │ ├── Resolves colId → PgCollection + │ │ │ ├── For map/collection creates: calls createPgMap/createPgCollection + │ │ │ └── For features: builds PgWrite wrapper + │ │ │ + │ │ ├── groupOperations() + │ │ │ ├── Groups writes by map → collection → partition → op + │ │ │ └── For CREATE/UPSERT/UPDATE: calls StorageTx.created()/updated() + │ │ │ └── Builds Tuple: + │ │ │ ├── buildMembers() → IBook with metadata (updated_at, author, hash, etc.) + │ │ │ ├── Encodes feature (Naksha.encodeFeature → JBON/JSON bytes) + │ │ │ ├── Encodes geometry (Naksha.encodeGeometry → TWKB bytes) + │ │ │ └── Returns Tuple(storage#, map#, col#, fn, version, members, feature) + │ │ │ + │ │ └── executeWrite(map, col, partition, byOp) + │ │ ├── PgWriterInsert.execute(conn) → INSERT SQL + │ │ ├── PgWriterUpsert.execute(conn) → CTE-based UPSERT SQL + │ │ ├── PgWriterUpdate.execute(conn) → UPDATE with version check + │ │ └── PgWriterDelete.execute(conn) → tombstone/PURGE SQL + │ │ + │ └── Returns SuccessResponse with tuple numbers + │ + └── 4. Commit + session.commit() + ├── Persists transaction record to admin map + └── conn.commit() +``` + +## Key Abstractions for New Features + +| Concept | File | Purpose | +|---------|------|---------| +| **`Write`** | `lib-model/..request/Write.kt` | DSL for CRUD ops: `createFeature()`, `upsertFeature()`, etc. | +| **`WriteOp`** | `lib-model/..request/WriteOp.kt` | Enum: CREATE, UPSERT, UPDATE, DELETE, PURGE | +| **`Tuple`** | `lib-model/../Tuple.kt` | Immutable feature state: address (storage/map/col/fn/version) + members + feature bytes | +| **`StorageTx`** | `lib-model/../StorageTx.kt` | Builds Tuples from features: `created()`, `updated()`, `deleted()` | +| **`IMemberProcessor`** | `lib-model/../IMemberProcessor.kt` | Extension point for pre-persistence member mutation | +| **`PgWriter`** | `lib-psql/../PgWriter.kt` | Groups writes, dispatches to op-specific writers | +| **`PgWriterInsert`** | `lib-psql/../PgWriterInsert.kt` | SQL INSERT generation | +| **`PgWriterUpsert`** | `lib-psql/../PgWriterUpsert.kt` | CTE-based conditional insert/update | +| **`PgSession`** | `lib-psql/../PgSession.kt` | Connection management, `execute()` routing | + +## Extension Points + +1. **New storage backend**: Extend `AbstractStorage`, implement `ISession`, `PgWriter`-equivalent classes +2. **Custom member processing**: `session.addMemberProcessor(memberName, processor)` — hooks into pre-persistence pipeline +3. **New write operations**: Add to `WriteOp` enum, create new `PgWriter*` class, add dispatch in `PgWriter.executeWrite()` + +## Members Extraction: From NakshaFeature to PostgreSQL Columns + +### Three-Stage Write Path + +**Stage 1: `StorageTx.buildMembers()` → `IBook` (in-memory members dict)** + +File: `lib-model/../StorageTx.kt:114-162` + +During `PgWriter.groupOperations()`, each write calls `tx.created()`/`tx.updated()`/`tx.deleted()` which invokes `buildTuple()` → `buildMembers()`. A `HeapBook` is created with all standard members extracted from the `NakshaFeature`: + +``` +NakshaFeature + ├── feature.id → StandardMembers.Id + ├── feature.geometry → StandardMembers.Geometry (TWKB bytes) + ├── feature.referencePoint → StandardMembers.ReferencePoint (TWKB bytes) + ├── feature.properties.xyz.updatedAt → StandardMembers.UpdatedAt + ├── feature.properties.xyz.createdAt → StandardMembers.CreatedAt + ├── feature.properties.xyz.author → StandardMembers.Author + ├── feature.properties.xyz.authorTs → StandardMembers.AuthorTimestamp + ├── feature.properties.xyz.appId → StandardMembers.AppId + ├── feature.properties.xyz.changeCount → StandardMembers.ChangeCount (+1) + ├── feature.properties.xyz.tags → StandardMembers.Tags (JSON string) + ├── feature.properties.xyz.hash → StandardMembers.Hash (computed) + ├── feature.properties.xyz.hereTile → StandardMembers.HereTile (computed) + ├── feature.properties.xyz.featureType → StandardMembers.FeatureType + ├── feature.properties.xyz.cv0-3 → StandardMembers.CustomValue0-3 + ├── feature.properties.xyz.cs0-3 → StandardMembers.CustomString0-3 + └── attachment → StandardMembers.Attachment +``` + +The resulting `IBook` is stored on `Tuple.members`. + +**Stage 2: `PgColumnRows[row] = tuple` → Members into column arrays** + +File: `lib-psql/../PgColumnRows.kt:384-417` + +In the `PgWriterInsert`/`PgWriterUpsert`/`PgWriterUpdate` constructors, the `inRows` (`PgColumnRows`) is populated: + +```kotlin +// PgWriterInsert.kt:30-37 +for (write in writes) { + val tuple = write.tuple + if (tuple != null) { + inRows[i] = tuple // extracts IBook → column arrays + inRows.setCustomMembers(i, write.feature, members) // custom members + i++ + } +} +``` + +`PgColumnRows.set(row, tuple)` walks `tuple.members` by name and assigns each value into the corresponding typed column array (e.g., `set(row, PgColumn.updated_at, members.getByName("updated_at") as? Int64)`). + +**Stage 3: `inRows.values()` → PostgreSQL UNNEST** + +File: `lib-psql/../PgWriterInsert.kt:43-98` + +The column arrays are passed as prepared statement parameters to a multi-row `UNNEST` INSERT: + +```sql +WITH new_row AS ( + SELECT * FROM UNNEST($1, $2, $3, ...) AS t(fn, version, id, feature, ...) +) +INSERT INTO head_table (fn, version, id, feature, ...) +SELECT * FROM new_row +``` + +### Custom Members Flow + +File: `lib-psql/../PgCustomMemberValues.kt` + +For user-declared custom members on the collection: + +1. **`PgWriterInsert.init`** → `inRows.addCustomMembers(collection.head.members)` — adds column entries for each custom member +2. **`PgColumnRows.setCustomMembers(row, feature, members)`** — walks the feature using each member's `effectivePath()` via `PgCustomMemberValues.walkFeature()`, coerces the type via `PgCustomMemberValues.coerce()`, sets the column value +3. **`PgColumnRows[row] = tuple`** does NOT handle custom members — only the built-in `StandardMembers` + +### Members Book Creation — All Locations + +| Location | File:Line | Purpose | +|---------|-----------|---------| +| `StorageTx.buildMembers()` | `StorageTx.kt:139` | **Write path** — creates `HeapBook` from `NakshaFeature`, called during tuple construction in `PgWriter.groupOperations()` | +| `Naksha.decodeTuple()` | `Naksha.kt:520` | **Read path** — creates `HeapBook` when decoding a `Tuple` back into a `NakshaFeature` | +| `Naksha.decodeTuple()` | `Naksha.kt:581` | **Read path (alt)** — second decode path for `Tuple` → `NakshaFeature` | +| `PgColumnRows.getTuple()` | `PgColumnRows.kt:304` | **Read path** — creates `PgRowDict` (implements `IBook`) wrapping DB row columns, assigned to `Tuple.members` | + +### End-to-End Data Flow + +``` + WRITE PATH READ PATH + ┌─────────────────────┐ ┌──────────────────────┐ + NakshaFeature│ StorageTx │ │ PgRowDict │ + properties│ .buildMembers() │ │ (PgColumnRows[row]) │ + │ ───────────────── │ │ ─────────────────── │ + │ xyz.updatedAt ─────┼─→ IBook │ column "updated_at" │──→ Tuple.members.getByName() + │ xyz.author ───────┼─→ .put() │ column "author" │──→ NakshaFeature.xyz.author + │ geometry ───────┼─→ .put() │ column "geo" │──→ NakshaFeature.geometry + │ feature blob │ │ column "feature" │──→ NakshaFeature (decode) + └────────┌────────────┘ └──────────┬───────────┘ + │ │ + ▼ ▲ + ┌─────────────────────┐ ┌──────────────────────┐ + │ PgColumnRows │ │ PgColumnRows │ + │ .set(row, tuple) │──→ SQL │ .add(cursor) │ + │ .setCustomMembers()│ UNNEST │ .getTuple(row) │ + └─────────────────────┘ └──────────────────────┘ + │ ▲ + ▼ │ + ┌─────────────────────┐ ┌──────────────────────┐ + │ PostgreSQL │ │ PostgreSQL │ + │ HEAD table │ ────┐ │ HEAD/HISTORY │ + │ (fn, version, id, │ │ │ SELECT ... │ + │ feature, geo, │ │ │ FROM head_table │ + │ author, ...) │ │ │ WHERE ... │ + └─────────────────────┘ │ └──────────────────────┘ + │ + PostgreSQL DB │ + │ +``` diff --git a/docs/latest/JBON2.md b/docs/latest/JBON2.md index 6184dd94e7..bd6fd674dc 100644 --- a/docs/latest/JBON2.md +++ b/docs/latest/JBON2.md @@ -28,11 +28,11 @@ As the format name indicates, this format is object-oriented. All **JBON** data - `Primitive`: The following _**units**_ are called _primitives_: `null`, `boolean`, `integer`, `float`, `timestamp`, `string`, and `tuple-number`. - `String`: A special _primitive_ that encodes a list of [UNICODE] code points, optionally including [references] to sub-strings. Strings are split using the [UNICODE] word boundary algorithm from [ICU4J]. - `Array` _(`List`)_: A list of arbitrary _**units**_ with significant order _(changing the order creates a different array)_. -- `Set` _(`List`)_: A list of unique non-null _**primitives**_; the order of the elements is not significant, therefore the encoder will optimize the order by sorting the elements. +- `TagList` _(`List`)_: A list of unique non-null _**strings**_; the order of the elements is significant, and the list must not have duplicates, null or undefined. - `Object` _(`Map`)_: A list of key-value pairs with all keys being unique non-null _**strings**_; the values can be any _**unit**_. The order of the entries is not significant, therefore the encoder will optimize by sorting the entries by their keys. - `Map` _(`Map`)_: A list of key-value pairs with keys limited to be unique non-null _**primitives**_; values can be any _**unit**_. The order of the entries is not significant, therefore the encoder will optimize by sorting the entries by their keys. - `Dictionary` _(`Map`)_: A list of key-value pairs with keys being unique non-null _**strings**_ and values being non-null _**strings**_. The order of the entries is not significant, therefore the encoder will optimize by sorting the entries by their keys. -- `Tags` _(`Map`)_: A list of key-value pairs with keys being unique non-null _**strings**_, and the values being any _**primitive**_. The order of the entries is not significant, therefore the encoder will optimize by sorting the entries by their keys. +- `TagMap` _(`Map`)_: A list of key-value pairs with keys being unique non-null _**strings**_, and the values being any _**primitive**_. The order of the entries is not significant, therefore the encoder will optimize by sorting the entries by their keys. - `Book` _(`List`)_: An addressable array of _**units**_ loaded into context. - `Tuple`: A special encoding of a _feature_ being an `Object` with some metadata to cooperate with the [Naksha data model]. - `TWKB`: An embedded geometry encoded in [Tiny WKB]. @@ -71,7 +71,7 @@ Whenever data is sorted, the following sort order should be used: - Strings, are converted into their [UTF16 string], then sorted by their UTF-16 code-units, so that the sorting is compatible with JavaScript and Java. - This is not a locale-aware alphabetical sorting. - If a [UTF16 string] is part of the **JBON**, it is treated exactly like a string, except that no conversion is needed. -- All structures (including [Array], [Map], [Set], [Object], [Tags], [Dictionary], [Book], [TupleNumberArray], [Tuple], [TWKB], and [Binary]) sort after all primitives, by their [logical bytes]. +- All structures (including [Array], [Map], [TagList], [Object], [TagMap], [Dictionary], [Book], [TupleNumberArray], [Tuple], [TWKB], and [Binary]) sort after all primitives, by their [logical bytes]. Beware that [references] can't be sorted, they always behave exactly like the value to which they refer. So, when a reference to a [string] is given, the sorting is based on the value of the [string], not on the reference itself. @@ -176,9 +176,9 @@ All _**units**_ start with a **lead-in** byte, which describes the actual type o - ss=3 / `11`: Size is **uint32**, 4 byte unsigned integer size - tttt= 0 / `11ss_0000`: [Array] - tttt= 1 / `11ss_0001`: [Map] - - tttt= 2 / `11ss_0010`: [Set] + - tttt= 2 / `11ss_0010`: [TagList] - tttt= 3 / `11ss_0011`: [Object] - - tttt= 4 / `11ss_0100`: [Tags] + - tttt= 4 / `11ss_0100`: [TagMap] - tttt= 5 / `11ss_0101`: [Dictionary] - tttt= 6 / `11ss_0110`: [Book] - tttt= 7 / `11ss_0111`: [TupleNumberArray] @@ -253,7 +253,7 @@ The following types are indexable: - string - byte[] - tuple-number -- tags +- tagmap ## Timestamp A timestamp, encoded with a **lead-in** byte `0000_1100`. It encodes a unix epoch timestamp (UTC) in milliseconds, stored in big-endian encoding as 7-byte value following the **lead-in**. Therefore, it belongs to the primitives. We choose this encoding, because a year has 31,536,000,000 milliseconds, therefore 36-bit can encode 2 years, 40-bit encode 34 years, 48-bit encode already 8925 years, with 56-bit encoding around 2 million years, more than enough. Reducing the size from full 8 byte to 7 byte, saves one byte per value, but more significant, it allows to read timestamps atomically as a single 64-bit integer, then binary-ANDing with `0x00FF_FFFF_FFFF_FFFF` to get the timestamp in milliseconds. @@ -499,7 +499,7 @@ If it is not empty, then the lowest four bit (`tttt`) encode the type of the str For the `Type` column in the following structure tables the maximum allowed type is used. The **lead-in** is always a single byte of type `byte` with the following size described as `int32`, encoded either as 1 byte, 2 byte or 4 byte unsigned integer in big-endian byte-order. -When `ss=0` _(empty structure)_, the `byte_size` field is omitted; the total size of the structure is exactly 1 byte _(the **lead-in** only)_. All other fields shown in the structure tables below are likewise absent in the empty form. The `ss=00` _(empty form)_ is only meaningful for [Array], [Map], [Set], [Object], [Tags], [Dictionary] and [TupleNumberArray] _(where it represents the empty collection)_. For [Book], [Tuple], [TWKB], [Binary], and [UTF16 String], the `ss=00` form is invalid; decoders **MUST** reject it as malformed, and encoders **MUST NOT** emit it. +When `ss=0` _(empty structure)_, the `byte_size` field is omitted; the total size of the structure is exactly 1 byte _(the **lead-in** only)_. All other fields shown in the structure tables below are likewise absent in the empty form. The `ss=00` _(empty form)_ is only meaningful for [Array], [Map], [TagList], [Object], [TagMap], [Dictionary] and [TupleNumberArray] _(where it represents the empty collection)_. For [Book], [Tuple], [TWKB], [Binary], and [UTF16 String], the `ss=00` form is invalid; decoders **MUST** reject it as malformed, and encoders **MUST NOT** emit it. All other values are variable encoded, for example `int` means any integer, `float` means any floating-point number. Therefore, an `int` with value `0` can be encoded as `int4`, the float `1.0` as `float4`. A question mark _(`?`)_ behind a type means that the value is nullable, so `null` can be stored instead of the actual value. If that is not the case, the value must not be `null`, nor a [reference] to `null` is allowed. Beware that all _**units**_ can always be replaced with a [reference] to relocate the _**unit**_ into a [book]. @@ -588,35 +588,33 @@ For the keys, the [primitive-stringification] is used, if needed. --- -### Set (2) -A set is a special [map] that does not store values, therefore it is a key-only map. The **lead-in** byte is `11ss_0010`; with `ss` encoding the size of the size, as usual. +### TagList (2) +A TagList is a list of unique non-null _**string**_; the order of the elements is significant, and the list must not have duplicates, null or undefined. The **lead-in** byte is `11ss_0010`; with `ss` encoding the size of the size, as usual. -| Name | Type | Description | -|-----------|------------------|-----------------------------------------------------------------------| -| lead_in | `byte` | The **lead-in** byte, `11ss_0010`. | -| byte_size | `int32` | The total size of the structure, including the **lead-in**, in bytes. | -| | | | -| entries | ([primitive])... | The entries of the set. | +| Name | Type | Description | +|-----------|---------------|-----------------------------------------------------------------------| +| lead_in | `byte` | The **lead-in** byte, `11ss_0010`. | +| byte_size | `int32` | The total size of the structure, including the **lead-in**, in bytes. | +| | | | +| entries | ([string])... | The entries of the TagList. | -If `ss=00` _(**lead-in** is `1100_0010`)_, this implies an empty set _(`{"@type":"naksha:set"}`)_. +If `ss=00` _(**lead-in** is `1100_0010`)_, this implies an empty TagList _(`{"@type":"naksha:taglist"}`)_. -The entries in a set are not sorted, the order is significant. The entries must not be `null`, `undefined` or duplicates. +The entries in a TagList are not sorted, the order is significant. The entries must not be `null`, `undefined` or duplicates. #### Logical Bytes -The [logical bytes] of the set are calculated by adding the **lead-in** `1111_0010`, followed by the byte-size as 32-bit BE integer, followed by all `entries` [sorted] in ascending order. The same rules apply while generating the [logical bytes] that apply generally when encoding [logical bytes]. So, `entries` being [references] have to be treated as if they were embedded, so they need to be added to the [logical bytes] the same way that real embedded values are. +The [logical bytes] of the TagList are calculated by adding the **lead-in** `1111_0010`, followed by the byte-size as 32-bit BE integer, followed by all `entries` in their given order. The same rules apply while generating the [logical bytes] that apply generally when encoding [logical bytes]. So, `entries` being [references] have to be treated as if they were embedded, so they need to be added to the [logical bytes] the same way that real embedded values are. #### JSON -The [JSON] serialization is done as object with values being `null`, and with a special type property: +The [JSON] serialization is done as a normal array: ```javascript -var set = { - "@type": "naksha:set", - "entries": [] -} +var tagList = [ + "foo", + "bar" +] ``` -In [JSON] we have no better alternative to encode a set. For the entries, the [primitive-stringification] is used, if needed. - ### Object (3) An object is a special [map] that only allows strings as keys. The **lead-in** byte is `11ss_0011`; with `ss` encoding the size of the size, as usual. All keys must be [strings]. @@ -645,8 +643,8 @@ However, in conflict case the explicit type name is `naksha:object`, needed only --- -### Tags (4) -The tags are a special [map] that allow only strings as keys and [primitives] as values. The **lead-in** byte is `11ss_0100`; with `ss` encoding the size of the size, as usual. +### TagMap (4) +A TagMap is a map with unique non-null _**string**_ keys and _**primitive**_ values. The **lead-in** byte is `11ss_0100`; with `ss` encoding the size of the size, as usual. | Name | Type | Description | |-----------|-----------------------------|-----------------------------------------------------------------------| @@ -655,20 +653,20 @@ The tags are a special [map] that allow only strings as keys and [primitives] as | | | | | entries | ([string], [primitive]?)... | The key-value pairs. | -If `ss=00` _(**lead-in** is `1100_0100`)_, this implies empty tags _(`{"@type":"naksha:tags"}`)_. +If `ss=00` _(**lead-in** is `1100_0100`)_, this implies an empty TagMap _(`{"@type":"naksha:tagmap"}`)_. -The entries in the maps are always encoded [sorted] ascending by the key. +The entries in the TagMap are always encoded [sorted] ascending by the key. #### Logical Bytes -The [logical bytes] of the tags are created by adding the **lead-in** `1111_0100`, followed by the byte-size as 32-bit BE integer, then all entries in order _(therefore, [sorted] by key, ascending)_. +The [logical bytes] of the TagMap are created by adding the **lead-in** `1111_0100`, followed by the byte-size as 32-bit BE integer, then all entries in order _(therefore, [sorted] by key, ascending)_. #### JSON The [JSON] serialization is as object with special type property: ```javascript -var tags = { - "@type": "naksha:tags", - "tag_name": tag_value +var tagMap = { + "@type": "naksha:tagmap", + "key_name": key_value } ``` @@ -868,15 +866,15 @@ The `database_number` of the `global` [book] **MUST** match the `database_number The `members` [book] is per-tuple and travels with the tuple. This means, the storage need to keep the content of the `members` [book] next to the `tuple` and always read them together. It can embed the members into the tuple, or do something else. For example, in `lib-psql` _(the PostgreSQL implementation of `lib-data` storage API)_ the members are stored as own dedicated columns. So, `lib-psql` will store `feature` _(the actual tuple)_, `fn`, `version`, `global_book_fn`, `next_version`, and `id` as dedicated database columns. If more members are defined for a collection, then `lib-psql` will generate more columns in the storage. This is as well the reason why the encoder always needs the collection specification, because the members are not arbitrary, they **MUST** match exactly the specified ones in the collection definition. However, for the decoder this is not important, the decoder has references in the _feature_ that refer to the member slots, therefore, it does not need any knowledge about the collection. It is able to decode the tuple with only the members [book] provided by the storage. -Some `elements` of the `members` [book] have a pre-defined meaning _(therefore custom members **MUST** have an `index` starting at 4)_: +Some `elements` of the `members` [book] have a pre-defined meaning: -| i | Alias | Path | Type | Description | -|------|------------------|---------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| 0 | `tn` | `tn` | [TupleNumber] | The [Tuple-Number] of this tuple. | -| 1 | `global_book_fn` | (none) | [int]? | The _optional_ feature-number of the `global` [book] needed to decode; `null` if no global book is needed. | -| 2 | `next_version` | `nv` | [uint56] | The next version of the tuple; if the tuple is in _HEAD_ state the value will be `9_007_199_254_740_991L` _(JavaScript's `Number.MAX_SAFE_INTEGER`)_. | -| 3 | `id` | `id` | [String]? | The _optional_ identifier of this tuple; a string when the feature-number is negative; `null` when the feature-number is positive (≥ 0). | -| 4... | ... | ... | [indexable]? | All custom members appended starting here, types **MUST** be [indexable]. | +| Name | Path | Type | Description | +|------------------|--------|---------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `tn` | `tn` | [TupleNumber] | The [Tuple-Number] of this tuple. | +| `global_book_fn` | `gbfn` | [int]? | The _optional_ feature-number of the `global` [book] needed to decode; `null` if no global book is needed. | +| `next_version` | `nv` | [uint56] | The next version of the tuple; if the tuple is in _HEAD_ state the value will be `9_007_199_254_740_991L`. | +| `id` | `id` | [String]? | The _optional_ identifier of this tuple; a string when the feature-number is negative; `null` when the feature-number is positive (≥ 0). | +| ... | ... | [indexable]? | All custom members appended starting here, types **MUST** be [indexable]. | The `next_version` MUST be encoded as [uint56] _(**lead-in** `0000_1101`)_, so it can be patched in place without changing the byte size of the tuple. @@ -1093,14 +1091,14 @@ object lead-in (1 byte) <-- root {…} string "highway" (8 byte) string "residential" (12 byte) string "maxSpeed" (9 byte) - tiny-int 50 (2 byte) <-- int8 lead-in + 1 byte value + int8 50 (2 byte) <-- int8 lead-in + 1 byte value string "oneway" (7 byte) boolean false (1 byte) = 1+1+3+3+5+8+11+1+1+8+12+9+2+7+1 = 73 byte ``` -So 73 byte per feature, two features ≈ **146 byte**, plus an array wrapper (lead-in + size = 2 byte) ≈ **148 byte**. Compared to 195 byte [JSON], a saving of around **24%**, which is what you would also expect from [CBOR]. Not impressive — the binary is just more compact than text. +So 73 byte per feature. Two features (146 byte) plus the array wrapper (lead-in + size = 2 byte) = **148 byte** of payload, wrapped in an array structure (lead-in + byte_size = 2 byte) = **150 byte** total. Compared to 195 byte [JSON], a saving of around **23%**, which is what you would also expect from [CBOR]. Not impressive — the binary is just more compact than text. ### Medium compression: shared `local` book A **JBON** encoder can detect that both features share the same keys and values, and lift them into a `local` [book]. The book is embedded next to the features: @@ -1137,29 +1135,29 @@ object lead-in (1 byte) <-- root {…} ref "highway" (1 byte) <-- mref4 ref "residential" (1 byte) <-- mref4 ref "maxSpeed" (1 byte) <-- mref4 - tiny-int 50 (2 byte) + int8 50 (2 byte) ref "oneway" (1 byte) <-- mref4 boolean false (1 byte) = 1+1+1+3+1+1+1+1+1+1+1+1+2+1+1 = 18 byte ``` -So per feature 18 byte. Two features (36 byte) plus the array wrapper (2 byte) plus the `local` [book] (67 byte) ≈ **105 byte**. That is **46% smaller** than [JSON] (195 byte), and crucially the saving grows with the number of features: at 100 features the book amortises over 100 × 18 + 67 ≈ 1867 byte, vs. ~9700 byte of [JSON] — an **81% reduction**. +So per feature 18 byte. Two features (36 byte) plus the `local` [book] (67 byte) plus the array wrapper (2 byte) = **105 byte** of payload, wrapped in an array structure (2 byte) = **107 byte** total. That is **45% smaller** than [JSON] (195 byte), and crucially the saving grows with the number of features: at 100 features the book amortises over 100 × 18 + 67 + 2 ≈ 1869 byte, vs. ~9700 byte of [JSON] — an **81% reduction**. ### Large compression: shared `global` book with template -If a `global` [book] is provided that defines a template _feature_ — i.e. an [Object] with `type:"Feature"`, `properties.highway:"residential"`, `properties.maxSpeed:50`, `properties.oneway:false` as default values — then a feature that matches the template only needs to encode its own `id`: +If a `global` [book] is provided that defines a template _feature_ — i.e. an [Object] with `type:"Feature"`, `properties.highway:"residential"`, `properties.maxSpeed:50`, `properties.oneway:false` as default values — then a feature that matches the template only needs to encode a reference to that template plus its own `id`. The decoder merges the template defaults with the explicitly encoded key-value pairs, so the resulting object appears as if all properties were present in the binary: ``` object lead-in (1 byte) <-- root {…} byte_size (1 byte) - ref to template (2 byte) <-- ref8 into global book + ref to properties (2 byte) <-- ref8 into global book of shared properties ref "id" (1 byte) <-- mref4 string "f1" (3 byte) = 1+1+2+1+3 = 8 byte ``` -So per feature 8 byte. Two features (16 byte) plus array wrapper (2 byte) ≈ **18 byte**, while the `global` [book] is **not** part of the file (it is shared across features, possibly across whole collections). This is a **91% reduction** vs. [JSON] (195 byte) — and the ratio improves further as more features share the same template. +So per feature 8 byte. Two features (16 byte) plus array wrapper (2 byte) = **18 byte** of payload, wrapped in an array structure (2 byte) = **20 byte** total, while the `global` [book] is **not** part of the file (it is shared across features, possibly across whole collections). This is a **90% reduction** vs. [JSON] (195 byte) — and the ratio improves further as more features share the same template. At 1,000,000 such features, [JSON] would be roughly 96 MB, while **JBON** with a shared `global` [book] would be roughly **8 MB**, regardless of where the [book] is stored. The `global` [book] itself is small _(a few hundred bytes for this example)_ and is loaded once into the decoder. @@ -1168,9 +1166,9 @@ At 1,000,000 such features, [JSON] would be roughly 96 MB, while **JBON** with a | Encoding | Per feature | 2 features total | 1,000,000 features | Reduction (2 features) | Reduction (1M features) | |----------------------------|------------:|-----------------:|-------------------:|-----------------------:|------------------------:| | [JSON] | 96 byte | 195 byte | ~96 MB | 0% | 0% | -| **JBON** _(no book)_ | 73 byte | 148 byte | ~73 MB | 24% | 24% | -| **JBON** _(`local` book)_ | 18 byte | 105 byte | ~18 MB | 46% | 81% | -| **JBON** _(`global` book)_ | 8 byte | 18 byte | ~8 MB | 91% | 92% | +| **JBON** _(no book)_ | 73 byte | 150 byte | ~73 MB | 23% | 24% | +| **JBON** _(`local` book)_ | 18 byte | 107 byte | ~18 MB | 45% | 81% | +| **JBON** _(`global` book)_ | 8 byte | 20 byte | ~8 MB | 90% | 92% | The decisive observation is that none of these levels require a different decoder or a different format version; they all use the same **JBON** binary format. The encoder picks the level it wants, the decoder is oblivious. @@ -1285,10 +1283,10 @@ public enum JbonUnitType { BOOK, TUPLE_NUMBER, TUPLE, - SET, + TAG_LIST, MAP, DICTIONARY, - TAGS, + TAG_MAP, TWKB, BINARY } @@ -1374,7 +1372,7 @@ public class Jbon { public void leave(int return_address) { /* ... */ } } -// A thread-local JBON decoder that can support IArray, IObject, ISet, IMap, ITuple and ITupleNumber. +// A thread-local JBON decoder that can support IArray, IObject, ITagList, IMap, ITuple and ITupleNumber. public class JbonDecoder { public JbonDecoder(@NotNull Jbon jbon) { this.jbon = jbon; } @@ -1513,7 +1511,7 @@ public final class JbonArray extends JbonStruct implements IArray { public JbonArray(@NotNull JbonUnit unit) { super(unit); } } -public final class JbonSet extends JbonStruct implements ISet { +public final class JbonTagList extends JbonStruct implements ITagList { public JbonStruct(@NotNull JbonUnit unit) { super(unit); } } @@ -1776,13 +1774,13 @@ The following changes are introduced in version 2 of this specification, compare [Map]: #map-1 [map]: #map-1 [maps]: #map-1 -[Set]: #set-2 +[TagList]: #set-2 [set]: #set-2 [sets]: #set-2 [Object]: #object-3 [object]: #object-3 [objects]: #object-3 -[Tags]: #tags-4 +[TagMap]: #tags-4 [tags]: #tags-4 [Dictionary]: #dictionary-5 [dictionary]: #dictionary-5 diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/JWTPayload.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/JWTPayload.java index b2697f8563..bc97289c57 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/JWTPayload.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/JWTPayload.java @@ -60,7 +60,7 @@ public class JWTPayload { if (urm == null) { return null; } - final ServiceUserRights hereActionMatrix = urm.get(URMServiceId.NAKSHA); + final ServiceUserRights hereActionMatrix = (ServiceUserRights) urm.getPath(URMServiceId.NAKSHA); if (hereActionMatrix == null) { return null; } diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubActionMatrix.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubActionMatrix.java index b7e8524110..c3966860ea 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubActionMatrix.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubActionMatrix.java @@ -186,7 +186,7 @@ public void createConnector(@NotNull EventHandlerConfig eventHandler) { addAction(MANAGE_CONNECTORS, XyzHubAttributeMap.ofConnector(eventHandler)); // MANAGE_PACKAGES right is needed to add the connector to a packages. - List packages = JvmBoxingUtil.box(eventHandler.get(XyzHubAttributeMap.PACKAGES), StringList.class); + List packages = JvmBoxingUtil.box(eventHandler.getPath(XyzHubAttributeMap.PACKAGES), StringList.class); for (final @NotNull String packageId : packages) { addAction(MANAGE_PACKAGES, XyzHubAttributeMap.ofPackage(packageId)); } diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubAttributeMap.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubAttributeMap.java index eb0e5a0e07..f79312aa63 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubAttributeMap.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/auth/XyzHubAttributeMap.java @@ -116,8 +116,8 @@ public class XyzHubAttributeMap extends AttributeMap { attributeMap.withValue( XyzHubAttributeMap.AUTHOR, eventHandler.getProperties().getXyz().getAuthor()); - if (eventHandler.get(XyzHubAttributeMap.PACKAGES) != null) { - attributeMap.withValue(XyzHubAttributeMap.PACKAGES, eventHandler.get(XyzHubAttributeMap.PACKAGES)); // oneOf + if (eventHandler.getPath(XyzHubAttributeMap.PACKAGES) != null) { + attributeMap.withValue(XyzHubAttributeMap.PACKAGES, eventHandler.getPath(XyzHubAttributeMap.PACKAGES)); // oneOf } return attributeMap; } diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/AbstractApiTask.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/AbstractApiTask.java index 99981982fe..03a4a6f9b9 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/AbstractApiTask.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/AbstractApiTask.java @@ -211,9 +211,9 @@ private static String getIterateHandleAsString( return validatedErrorResponse; } else if (response instanceof SuccessResponse successResponse) { final Map> featureMap = postProcessedFeaturesByAction(successResponse, postProcessor); - final List insertedFeatures = featureMap.get(Action.CREATED); - final List updatedFeatures = featureMap.get(Action.UPDATED); - final List deletedFeatures = featureMap.get(Action.DELETED); + final List insertedFeatures = featureMap.get(Action.CREATE); + final List updatedFeatures = featureMap.get(Action.UPDATE); + final List deletedFeatures = featureMap.get(Action.DELETE); // extract violations if available List violations = null; if (successResponse instanceof ContextXyzFeatureResponse cr) { @@ -310,18 +310,18 @@ private static Map> postProcessedFeaturesByAction( for (NakshaFeature feature : features) { postProcessor.postProcess(feature); final Action action = feature.getProperties().getXyz().getAction(); - if (action == Action.CREATED) { + if (action == Action.CREATE) { insertedFeatures.add(feature); - } else if (action == Action.UPDATED) { + } else if (action == Action.UPDATE) { updatedFeatures.add(feature); - } else if (action == Action.DELETED) { + } else if (action == Action.DELETE) { deletedFeatures.add(feature); } } final Map> featuresByAction = new HashMap<>(); - featuresByAction.put(Action.CREATED, insertedFeatures); - featuresByAction.put(Action.UPDATED, updatedFeatures); - featuresByAction.put(Action.DELETED, deletedFeatures); + featuresByAction.put(Action.CREATE, insertedFeatures); + featuresByAction.put(Action.UPDATE, updatedFeatures); + featuresByAction.put(Action.DELETE, deletedFeatures); return featuresByAction; } } diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/EventHandlerApiTask.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/EventHandlerApiTask.java index f5cc676ede..e7f99d2a0a 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/EventHandlerApiTask.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/EventHandlerApiTask.java @@ -106,8 +106,8 @@ protected void init() { private @NotNull XyzResponse executeGetHandlers() { // Create ReadFeatures Request to read all handlers from Admin DB - final ReadFeatures request = new ReadFeatures().addCollectionId(EVENT_HANDLERS); - request.setMapId(naksha().getAdminMapId()); + final ReadFeatures request = new ReadFeatures().withCollectionId(EVENT_HANDLERS); + request.setCatalogId(naksha().getAdminMapId()); // Submit request to NH Space Storage Response response = executeReadRequestFromSpaceStorage(request); // transform Response to Http FeatureCollection response @@ -117,8 +117,8 @@ protected void init() { private @NotNull XyzResponse executeGetHandlerById() { // Create ReadFeatures Request to read the handler with the specific ID from Admin DB final String handlerId = routingContext.pathParam(HANDLER_ID); - final ReadFeatures request = new ReadFeatures().addCollectionId(EVENT_HANDLERS); - request.setMapId(naksha().getAdminMapId()); + final ReadFeatures request = new ReadFeatures().withCollectionId(EVENT_HANDLERS); + request.setCatalogId(naksha().getAdminMapId()); request.setFeatureIds(StringList.of(handlerId)); // Submit request to NH Space Storage Response response = executeReadRequestFromSpaceStorage(request); diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/ReadFeatureApiTask.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/ReadFeatureApiTask.java index b7728f4022..092e5a77b6 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/ReadFeatureApiTask.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/ReadFeatureApiTask.java @@ -337,7 +337,7 @@ protected void init() { .withPropertyQuery(propertyQuery) .withTagQuery(tagQuery); rdRequest.setFeatureIds(suppliedFeatureIds); - rdRequest.setCollectionIds(StringList.of(spaceId)); + rdRequest.setCollectionId(spaceId); rdRequest.setLimit(limit); // Forward request to NH Space Storage reader instance @@ -422,7 +422,7 @@ protected void init() { query.setTags(tagQuery); final ReadFeatures rdRequest = new ReadFeatures(); rdRequest.setFeatureIds(suppliedFeatureIds); - rdRequest.setCollectionIds(StringList.of(spaceId)); + rdRequest.setCollectionId(spaceId); rdRequest.setQuery(query); rdRequest.withPropertyQuery(propertyQuery); @@ -502,7 +502,7 @@ protected void init() { query.setSpatial(radiusQuery); query.setTags(tagQuery); final ReadFeatures rdRequest = new ReadFeatures(); - rdRequest.setCollectionIds(StringList.of(spaceId)); + rdRequest.setCollectionId(spaceId); rdRequest.setFeatureIds(suppliedFeatureIds); rdRequest.setQuery(query); rdRequest.withPropertyQuery(propertyQuery); diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/SpaceApiTask.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/SpaceApiTask.java index 27f24636f5..bceee9b20d 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/SpaceApiTask.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/SpaceApiTask.java @@ -132,16 +132,16 @@ private XyzResponse executeDeleteSpace() { } private @NotNull XyzResponse executeGetSpaces() { - final ReadFeatures request = new ReadFeatures().addCollectionId(SPACES); - request.setMapId(naksha().getAdminMapId()); + final ReadFeatures request = new ReadFeatures().withCollectionId(SPACES); + request.setCatalogId(naksha().getAdminMapId()); Response response = executeReadRequestFromSpaceStorage(request); return transformResponseToXyzCollectionResponse(response, Space.class, 0, DEF_ADMIN_FEATURE_LIMIT, null, null); } private @NotNull XyzResponse executeGetSpaceById() { final String spaceId = extractMandatoryPathParam(routingContext, SPACE_ID); - final ReadFeatures request = new ReadFeatures().addCollectionId(SPACES); - request.setMapId(naksha().getAdminMapId()); + final ReadFeatures request = new ReadFeatures().withCollectionId(SPACES); + request.setCatalogId(naksha().getAdminMapId()); request.setFeatureIds(StringList.of(spaceId)); Response response = executeReadRequestFromSpaceStorage(request); return transformResponseToXyzFeatureResponse(response, Space.class, NOT_FOUND_ON_NO_ELEMENTS); diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/StorageApiTask.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/StorageApiTask.java index 36cd9df3e0..47854cc718 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/StorageApiTask.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/StorageApiTask.java @@ -103,16 +103,16 @@ protected void init() { } private @NotNull XyzResponse executeGetStorages() { - final ReadFeatures request = new ReadFeatures().addCollectionId(STORAGES); - request.setMapId(naksha().getAdminMapId()); + final ReadFeatures request = new ReadFeatures().withCollectionId(STORAGES); + request.setCatalogId(naksha().getAdminMapId()); Response response = executeReadRequestFromSpaceStorage(request); return transformResponseToXyzCollectionResponse(response, NakshaStorage.class, STORAGE_MASKING); } private @NotNull XyzResponse executeGetStorageById() { final String storageId = ApiParams.extractMandatoryPathParam(routingContext, STORAGE_ID); - final ReadFeatures request = new ReadFeatures().addCollectionId(STORAGES); - request.setMapId(naksha().getAdminMapId()); + final ReadFeatures request = new ReadFeatures().withCollectionId(STORAGES); + request.setCatalogId(naksha().getAdminMapId()); request.setFeatureIds(StringList.of(storageId)); return transformedResponseTo(request); } diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/WriteFeatureApiTask.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/WriteFeatureApiTask.java index ce3c7af838..6bbae57be9 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/WriteFeatureApiTask.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/http/tasks/WriteFeatureApiTask.java @@ -327,7 +327,7 @@ private XyzResponse patchAndPreProcess( // Prepare WriteRequest - separating insert from updates and keeping the order of the features from the request WriteRequest insertsAndUpdates = new WriteRequest(); for (NakshaFeature featureFromRequest : featuresFromRequest) { - NakshaFeature correspondingExistingFeature = existingFeaturesById.get(featureFromRequest.getId()); + NakshaFeature correspondingExistingFeature = (NakshaFeature) existingFeaturesById.getPath(featureFromRequest.getId()); if (correspondingExistingFeature == null) { // Feature not yet persisted - just insert preProcessor.preProcess(featureFromRequest); diff --git a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/util/NakshaAdminRequestUtil.java b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/util/NakshaAdminRequestUtil.java index 206a538475..8cca63eacc 100644 --- a/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/util/NakshaAdminRequestUtil.java +++ b/here-naksha-app-service/src/jvmMain/java/com/here/naksha/app/service/util/NakshaAdminRequestUtil.java @@ -48,8 +48,8 @@ public static WriteRequest deleteEventHandlerRequest(INaksha naksha, String hand private static ReadFeatures getAdminResourcesRequest(INaksha naksha, String resourceCollection) { ReadFeatures readFeatures = new ReadFeatures(); - readFeatures.setMapId(naksha.getAdminMapId()); - readFeatures.addCollectionId(resourceCollection); + readFeatures.setCatalogId(naksha.getAdminMapId()); + readFeatures.withCollectionId(resourceCollection); return readFeatures; } diff --git a/here-naksha-app-service/src/jvmTest/java/com/here/naksha/app/service/UpdateFeatureTest.java b/here-naksha-app-service/src/jvmTest/java/com/here/naksha/app/service/UpdateFeatureTest.java index dcef88c911..746448a21d 100644 --- a/here-naksha-app-service/src/jvmTest/java/com/here/naksha/app/service/UpdateFeatureTest.java +++ b/here-naksha-app-service/src/jvmTest/java/com/here/naksha/app/service/UpdateFeatureTest.java @@ -209,7 +209,7 @@ void tc0505_testUpdateFeaturesWithUuid() throws Exception { Assertions.assertNotNull(responseFeatureCollection); final NakshaFeature updatedFeature = responseFeatureCollection.getFeatures().get(0); - Assertions.assertEquals("30", updatedFeature.getProperties().get("speedLimit")); + Assertions.assertEquals("30", updatedFeature.getProperties().getPath("speedLimit")); // Execute request, outdated UUID, should fail feature.setProperties(newPropsOutdatedUuid); @@ -243,7 +243,7 @@ void tc0505_testUpdateFeaturesWithUuid() throws Exception { final XyzFeatureCollection featureCollection = parseJson(responseOverriding.body(), XyzFeatureCollection.class); Assertions.assertNotNull(featureCollection); final NakshaFeature overridenFeature = featureCollection.getFeatures().get(0); - Assertions.assertEquals("yesyesyes", overridenFeature.getProperties().get("overriden")); + Assertions.assertEquals("yesyesyes", overridenFeature.getProperties().getPath("overriden")); // Old properties like speedLimit should no longer be available // The feature has been completely overwritten by the PUT request with null UUID Assertions.assertFalse(overridenFeature.getProperties().containsKey("speedLimit")); @@ -278,8 +278,8 @@ void tc0506_testUpdateFeatureWithUuid() throws Exception { // And: update properties match final NakshaFeature updatedFeature = parseJson(responseUpdateSuccess.body(), NakshaFeature.class); - Assertions.assertEquals("30", updatedFeature.getProperties().get("speedLimit")); - Assertions.assertEquals("tc_506", updatedFeature.getProperties().get("this_test_id")); + Assertions.assertEquals("30", updatedFeature.getProperties().getPath("speedLimit")); + Assertions.assertEquals("tc_506", updatedFeature.getProperties().getPath("this_test_id")); // When: trying to update with outdated UUID final NakshaProperties newPropsOutdatedUuid = newPropsOldUuid.copy(true); @@ -302,7 +302,7 @@ void tc0506_testUpdateFeatureWithUuid() throws Exception { // Then: update suceeds assertThat(responseOverriding).hasStatus(200); final NakshaFeature overridenFeature = parseJson(responseOverriding.body(), NakshaFeature.class); - Assertions.assertEquals("yesyesyes", overridenFeature.getProperties().get("overriden")); + Assertions.assertEquals("yesyesyes", overridenFeature.getProperties().getPath("overriden")); // Old properties like isImportant should no longer be available // The feature has been completely overwritten by the PUT request with null UUID Assertions.assertFalse(overridenFeature.getProperties().containsKey("this_test_id")); diff --git a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/copy/service/CopyService.java b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/copy/service/CopyService.java index fe01b29a1c..8f0ccb7674 100644 --- a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/copy/service/CopyService.java +++ b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/copy/service/CopyService.java @@ -11,7 +11,7 @@ import naksha.model.NakshaException; import naksha.model.SessionOptions; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.request.*; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -112,10 +112,8 @@ private SuccessResponse requireSuccessResponse( private ReadFeatures createReadFeaturesRequest(CopyElement source) { ReadFeatures readFeatures = new ReadFeatures(); - readFeatures.setCollectionIds( - StringList.of(source.getCollectionId()) - ); - readFeatures.setMapId(source.getMapId()); + readFeatures.setCollectionId(source.getCollectionId()); + readFeatures.setCatalogId(source.getMapId()); return readFeatures; } @@ -158,7 +156,7 @@ private void createMapIfAbsent(IStorage storage, String mapId) throws CopyServic ); case ErrorResponse errorResponse -> { NakshaError nakshaError = errorResponse.getError(); - if (!nakshaError.getCode().equals(NakshaError.MAP_EXISTS)) { + if (!nakshaError.getCode().equals(NakshaError.CATALOG_EXISTS)) { throw new CopyServiceException("Problem with creating map!", new NakshaException(nakshaError)); } logger.info("Map(id: \"{}\") is already present on storage(id: \"{}\")!", mapId, storage.getId()); @@ -225,7 +223,7 @@ private Response performWriteRequest( private WriteRequest buildCreateMapRequest(String mapId) { WriteRequest writeRequest = new WriteRequest(); - NakshaMap map = new NakshaMap(mapId); + NakshaCatalog map = new NakshaCatalog(mapId); Write write = new Write().createMap(map); writeRequest.add(write); return writeRequest; @@ -234,7 +232,7 @@ private WriteRequest buildCreateMapRequest(String mapId) { private WriteRequest buildCreateCollectionRequest(CopyElement target) { WriteRequest writeRequest = new WriteRequest(); NakshaCollection collection = new NakshaCollection(target.getCollectionId()) - .withMapId(target.getMapId()); + .withCatalogId(target.getMapId()); Write write = new Write().createCollection(collection); writeRequest.add(write); return writeRequest; diff --git a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingSession.java b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingSession.java index 64c03c1129..1f988087f1 100644 --- a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingSession.java +++ b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingSession.java @@ -3,7 +3,7 @@ import naksha.model.*; import naksha.model.objects.NakshaCollection; import naksha.model.objects.NakshaFeature; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.request.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -34,15 +34,15 @@ public Response execute(@NotNull Request request) { } @Override - public void loadTuples(@NotNull List featureTuples) { + public void loadTuples(@NotNull List featureTuples, int from, int to) { GeneratingStorageService service = storage.getService(); List generatedFeatures = service.generateFeatures( - featureTuples.size(), + to - from, storage.getTileIds(), storage.getIdsPrefix(), templateFeature ); - for (int i = 0; i < featureTuples.size(); ++i) { + for (int i = from; i < to; ++i) { FeatureTuple featureTuple = featureTuples.get(i); NakshaFeature feature = generatedFeatures.get(i); featureTuple.setFeature(feature); @@ -109,30 +109,31 @@ public void setLockTimeout(int i) { @Nullable @Override - public NakshaMap getMapById(@NotNull String mapId) { + public NakshaCatalog getCatalogById(@NotNull String mapId) { throw new NakshaException(NakshaError.UNSUPPORTED_OPERATION, ""); } @Nullable @Override - public NakshaMap getMapByNumber(int mapNumber) { + public NakshaCatalog getCatalogByNumber(int catalogNumber) { throw new NakshaException(NakshaError.UNSUPPORTED_OPERATION, ""); } @Nullable @Override - public NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { + public NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { throw new NakshaException(NakshaError.UNSUPPORTED_OPERATION, ""); } @Nullable @Override - public NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { + public NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { throw new NakshaException(NakshaError.UNSUPPORTED_OPERATION, ""); } + private final MemberProcessorMap processors = new MemberProcessorMap(); @Override - public void loadTuples(@NotNull List featureTuples, int from, int to, int mode) { - throw new NakshaException(NakshaError.UNSUPPORTED_OPERATION, ""); + public @NotNull MemberProcessorMap getProcessors() { + return processors; } } diff --git a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorage.java b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorage.java index 85fc9ff09c..53d07a803f 100644 --- a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorage.java +++ b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorage.java @@ -13,7 +13,6 @@ import kotlin.reflect.KClass; import naksha.base.Platform; import naksha.base.StringList; -import naksha.jbon.JbDictionary; import naksha.model.*; import naksha.model.objects.NakshaFeature; import org.jetbrains.annotations.NotNull; @@ -67,17 +66,6 @@ public int getHardCap() { return Integer.MAX_VALUE; } - @Override - public @NotNull DataEncoding getDataEncoding(@Nullable Object feature, @Nullable Object context) { - return Naksha.DEFAULT_DATA_ENCODING; - } - - @Nullable - @Override - public JbDictionary getDictionary(@NotNull String id) { - return null; - } - @Override protected void initStorage( @NotNull GeneratingStorageConfig storageConfig, diff --git a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorageService.java b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorageService.java index 3daa126b92..4f3cd1847d 100644 --- a/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorageService.java +++ b/here-naksha-cli/src/jvmMain/java/com/here/naksha/cli/storages/GeneratingStorageService.java @@ -29,7 +29,7 @@ FeatureTupleList generateDummyFeatureTuples(Int64 storageNumber, int numOfTuples dummyFeatureTuples.setCapacity(numOfTuplesToGenerate); for (int i = 0; i < numOfTuplesToGenerate; ++i) { TupleNumber dummyTupleNumber = new TupleNumber( - storageNumber, 0, 0, Platform.intToInt64(0), Version.HEAD + storageNumber, 0, 0, Platform.intToInt64(0), Version.HEAD.number ); FeatureTuple dummyFeatureTuple = new FeatureTuple(dummyTupleNumber, null); dummyFeatureTuples.add(dummyFeatureTuple); diff --git a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTest.java b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTest.java index d43c9a1f89..d671547789 100644 --- a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTest.java +++ b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTest.java @@ -160,7 +160,7 @@ void shouldSucceedWithAutoCreateTargetAndAbsentTargetMapAndCollection() throws F void shouldSucceedWithAutoCreateTargetAndAbsentTargetCollection() throws FeaturesWriteExecutorException { // Given: valid target storage with write sessions IStorage targetStorage = createTargetStorage(sessionOptions); - IWriteSession createMapWriteSession = createWriteSessionReturningErrorResponse(NakshaError.MAP_EXISTS); + IWriteSession createMapWriteSession = createWriteSessionReturningErrorResponse(NakshaError.CATALOG_EXISTS); IWriteSession createCollectionWriteSession = createWriteSessionReturningSuccessResponse(); when(targetStorage.newWriteSession(sessionOptions)) .thenReturn(createMapWriteSession) @@ -220,7 +220,7 @@ void shouldSucceedWithAutoCreateTargetAndAbsentTargetCollection() throws Feature void shouldSucceedWithAutoCreateTargetAndExistingTargetMapAndCollection() throws FeaturesWriteExecutorException { // Given: valid target storage with write sessions IStorage targetStorage = createTargetStorage(sessionOptions); - IWriteSession createMapWriteSession = createWriteSessionReturningErrorResponse(NakshaError.MAP_EXISTS); + IWriteSession createMapWriteSession = createWriteSessionReturningErrorResponse(NakshaError.CATALOG_EXISTS); IWriteSession createCollectionWriteSession = createWriteSessionReturningErrorResponse(NakshaError.COLLECTION_EXISTS); when(targetStorage.newWriteSession(sessionOptions)) .thenReturn(createMapWriteSession) @@ -796,9 +796,9 @@ private IStorage createStorageWithFailingReadSession() { private void assertReadFeatures(List readFeaturesList) { assertEquals(1, readFeaturesList.size()); ReadFeatures readFeatures = readFeaturesList.getFirst(); - assertEquals(1, readFeatures.getCollectionIds().getSize()); - assertEquals(srcCopyElement.getCollectionId(), readFeatures.getCollectionIds().getFirst()); - assertEquals(srcCopyElement.getMapId(), readFeatures.getMapId()); + assertEquals(1, readFeatures.getCollectionId().getSize()); + assertEquals(srcCopyElement.getCollectionId(), readFeatures.getCollectionId().getFirst()); + assertEquals(srcCopyElement.getMapId(), readFeatures.getCatalogId()); } private IStorage createFailingSrcStorage() { @@ -839,16 +839,16 @@ private StorageProvider createStorageProvider(IStorage srcStorage, IStorage targ } private void assertCreateMapWrite(Write write) { - assertEquals(Naksha.ADMIN_MAP, write.getMapId()); - assertEquals(Naksha.CATALOGS_COL, write.getCollectionId()); + assertEquals(Naksha.ADMIN_CATALOG_ID, write.getCatalogId()); + assertEquals(Naksha.CATALOGS_COL_ID, write.getCollectionId()); assertEquals(WriteOp.CREATE, write.getOp()); assertNotNull(write.getFeature()); assertEquals(targetCopyElement.getMapId(), write.getFeature().getId()); } private void assertCreateCollectionWrite(Write write) { - assertEquals(targetCopyElement.getMapId(), write.getMapId()); - assertEquals(Naksha.COLLECTIONS_COL, write.getCollectionId()); + assertEquals(targetCopyElement.getMapId(), write.getCatalogId()); + assertEquals(Naksha.COLLECTIONS_COL_ID, write.getCollectionId()); assertEquals(WriteOp.CREATE, write.getOp()); assertNotNull(write.getFeature()); assertEquals(targetCopyElement.getCollectionId(), write.getFeature().getId()); diff --git a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTestUtils.java b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTestUtils.java index 4f41ba7613..d03057d989 100644 --- a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTestUtils.java +++ b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/CopyServiceTestUtils.java @@ -108,7 +108,7 @@ private static void assertCreateWrite(Write w, CopyElement target) { assertEquals(target.getCollectionId(), w.getCollectionId(), "Every write Collection ID should match target Collection ID" ); - assertEquals(target.getMapId(), w.getMapId(), + assertEquals(target.getMapId(), w.getCatalogId(), "Every write Map ID should match target Map ID" ); } diff --git a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/psql/PsqlCopyTest.java b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/psql/PsqlCopyTest.java index c510e0833d..a804e4c34e 100644 --- a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/psql/PsqlCopyTest.java +++ b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/copy/service/psql/PsqlCopyTest.java @@ -32,7 +32,7 @@ import naksha.model.SessionOptions; import naksha.model.objects.NakshaCollection; import naksha.model.objects.NakshaFeature; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.objects.NakshaStorage; import naksha.model.request.ReadFeatures; import naksha.model.request.Request; @@ -286,10 +286,10 @@ private SuccessResponse makeReadRequest(IStorage storage, Request request, Sessi private ReadFeatures createReadFeaturesRequest(String mapId, String collectionId) { ReadFeatures readFeatures = new ReadFeatures(); - readFeatures.setCollectionIds( + readFeatures.setCollectionId( StringList.of(collectionId) ); - readFeatures.setMapId(mapId); + readFeatures.setCatalogId(mapId); return readFeatures; } @@ -314,7 +314,7 @@ private String createUniqueMap(IStorage storage, SessionOptions sessionOptions) private void addMapToTheStorage(IStorage storage, String mapId, SessionOptions sessionOptions) { WriteRequest writeRequest = new WriteRequest(); - NakshaMap map = new NakshaMap().withId(mapId); + NakshaCatalog map = new NakshaCatalog().withId(mapId); Write createMap = new Write().createMap(map); writeRequest.add(createMap); diff --git a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/utils/JsonParserTest.java b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/utils/JsonParserTest.java index e974852a02..283c74ba35 100644 --- a/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/utils/JsonParserTest.java +++ b/here-naksha-cli/src/jvmTest/java/com/here/naksha/cli/utils/JsonParserTest.java @@ -105,6 +105,6 @@ void shouldProvideObject(@TempDir Path dir) throws IOException { // Then: key and value are present assertNotNull(object); - assertEquals("value", object.get("key")); + assertEquals("value", object.getPath("key")); } } \ No newline at end of file diff --git a/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogEnhancer.java b/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogEnhancer.java index eb2ddff770..5841ea39ef 100644 --- a/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogEnhancer.java +++ b/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogEnhancer.java @@ -85,9 +85,9 @@ private static void propagateVirtualPuuid(@Nullable XyzNs xyzNamespace, @NotNull private static @Nullable JsonNode calculateDiff( @Nullable Action action, @NotNull NakshaFeature newFeature, @Nullable NakshaFeature oldFeature) { - if (action == null || Action.CREATED.equals(action) || Action.DELETED.equals(action)) { + if (action == null || Action.CREATE.equals(action) || Action.DELETE.equals(action)) { return null; - } else if (Action.UPDATED.equals(action)) { + } else if (Action.UPDATE.equals(action)) { if (oldFeature == null) { logger.warn( "Unable to calculate reversePatch for, missing predecessor for feature with uuid: {}, returning null", diff --git a/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogHandler.java b/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogHandler.java index c3fee0184a..546189d47f 100644 --- a/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogHandler.java +++ b/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogHandler.java @@ -56,9 +56,8 @@ import naksha.model.request.SuccessResponse; import naksha.model.request.WriteRequest; import naksha.model.request.query.AnyOp; -import naksha.model.request.query.MetaColumn; -import naksha.model.request.query.MetaQuery; -import naksha.psql.PgLogLevel; +import naksha.model.request.query.MemberQuery; +import naksha.model.objects.StandardMembers; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -152,14 +151,14 @@ private List featuresWithPredecessors(CollectedFeatures * Such tombstones represent features that never participated in activity logging and should be excluded. */ private static boolean isOrphanTombstone(FeatureWithPredecessor fwp) { - return Action.DELETED.equals(fwp.feature().getProperties().getXyz().getAction()) + return Action.DELETE.equals(fwp.feature().getProperties().getXyz().getAction()) && fwp.oldFeature() == null; } private void collectMissingPredecessors(CollectedFeatures collectedFeatures, NakshaContext context) { List tnsOfRootsMissingPredecessor = collectedFeatures.activityLogRoots.stream() .filter(f -> !collectedFeatures.allByNuuid.containsKey(f.getId())) - .map(NakshaFeature::getTupleNumber) + .map(f -> StandardMembers.Tn.getTupleNumber(f)) .toList(); if (!tnsOfRootsMissingPredecessor.isEmpty()) { List missingPredecessorsByNextVersion = @@ -172,13 +171,13 @@ private ReadFeatures featuresWhereNextVersionIsOneOf(List tupleNumb // next_version is a plain int8 column, so we pass an Int64[] of the version values. Int64[] versions = new Int64[tupleNumbers.size()]; for (int i = 0; i < tupleNumbers.size(); i++) { - versions[i] = tupleNumbers.get(i).version.txn; + versions[i] = tupleNumbers.get(i).version; } - MetaQuery nextVersionQuery = new MetaQuery(MetaColumn.nextVersion(), AnyOp.IS_ANY_OF, versions); + MemberQuery nextVersionQuery = new MemberQuery(StandardMembers.NextVersion, AnyOp.IS_ANY_OF, versions); ReadFeatures requestPredecessors = new ReadFeatures(); - requestPredecessors.setCollectionIds(StringList.of(properties.getSpaceId())); + requestPredecessors.setCollectionId(properties.getSpaceId()); requestPredecessors.setQueryHistory(true); - requestPredecessors.getQuery().setMetadata(nextVersionQuery); + requestPredecessors.getQuery().setMembers(nextVersionQuery); return requestPredecessors; } @@ -250,7 +249,7 @@ private static XyzNs xyzNs(NakshaFeature feature) { * nuuid (ie UPDATE & DELETE) */ private static String nuuidOrNullIfDeleted(XyzNs xyzNs) { - if (Action.DELETED.equals(xyzNs.getAction())) { + if (Action.DELETE.equals(xyzNs.getAction())) { return null; } else { return xyzNs.getNuuid(); diff --git a/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtil.java b/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtil.java index 93937756c9..ecbde1693f 100644 --- a/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtil.java +++ b/here-naksha-handler-activitylog/src/jvmMain/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtil.java @@ -58,7 +58,7 @@ static void transformOriginalRequest(ReadFeatures readFeatures, String spaceId) readFeatures.setQueryHistory(true); readFeatures.setQueryDeleted(true); readFeatures.setVersions(Integer.MAX_VALUE); - readFeatures.setCollectionIds(StringList.of(spaceId)); + readFeatures.setCollectionId(spaceId); // extract UUIDs from featureIds, reset featureIds StringList rawGuids = readFeatures.getFeatureIds(); @@ -82,6 +82,7 @@ static void transformOriginalRequest(ReadFeatures readFeatures, String spaceId) } private static boolean isSingleActivityLogIdEqualityQuery(PQuery pQuery) { - return StringOp.EQUALS.equals(pQuery.getOp()) && pQuery.getProperty().getPath().containsStringsInOrder(ACTIVITY_LOG_ID_PATH); + return StringOp.EQUALS.equals(pQuery.getOp()) + && pQuery.getProperty().getPath().asList().stream().allMatch(s -> java.util.Arrays.asList(ACTIVITY_LOG_ID_PATH).contains(s)); } } diff --git a/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogHandlerTest.java b/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogHandlerTest.java index 41c98497e9..8a6f189df6 100644 --- a/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogHandlerTest.java +++ b/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogHandlerTest.java @@ -7,7 +7,6 @@ import static com.here.naksha.handler.activitylog.assertions.ActivityLogSuccessResultAssertions.assertThatResult; import static java.util.Collections.emptyList; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -29,10 +28,8 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; -import naksha.base.AnyList; -import naksha.base.Int64; -import naksha.base.JvmInt64; -import naksha.base.Timestamp; + +import naksha.base.*; import naksha.model.Action; import naksha.model.Guid; import naksha.model.IReadSession; @@ -44,6 +41,7 @@ import naksha.model.XyzNs; import naksha.model.objects.NakshaFeature; import naksha.model.objects.NakshaProperties; +import naksha.model.objects.XyzMembers; import naksha.model.request.ErrorResponse; import naksha.model.request.ReadCollections; import naksha.model.request.ReadFeatures; @@ -54,9 +52,8 @@ import naksha.model.request.Write; import naksha.model.request.WriteRequest; import naksha.model.request.query.AnyOp; -import naksha.model.request.query.IMetaQuery; -import naksha.model.request.query.MetaColumn; -import naksha.model.request.query.MetaQuery; +import naksha.model.request.query.IMemberQuery; +import naksha.model.request.query.MemberQuery; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -162,15 +159,15 @@ void shouldImmediatelySucceedOnWriteCollection() { void shouldComposeActivityFeatures() throws Exception { // Given: features uuid String featureId = "featureId"; - Guid initialUuid = guid(featureId, Version.now(new JvmInt64(1),Action.CREATED)); - Guid newUuid = guid(featureId, Version.now(new JvmInt64(2),Action.UPDATED)); + Guid initialUuid = guid(featureId, Version.now(new JvmInt64(1),Action.CREATE)); + Guid newUuid = guid(featureId, Version.now(new JvmInt64(2),Action.UPDATE)); // And: old version of feature NakshaFeature oldFeature = nakshaFeature(featureId) .withUuid(initialUuid.toString()) .withPuuid(null) .withNuuid(newUuid.toString()) - .withAction(Action.CREATED) + .withAction(Action.CREATE) .withCustomProperties( Map.of( "op", "old feature", @@ -183,7 +180,7 @@ void shouldComposeActivityFeatures() throws Exception { .withUuid(newUuid.toString()) .withPuuid(initialUuid.toString()) .withNuuid(null) - .withAction(Action.UPDATED) + .withAction(Action.UPDATE) .withCustomProperties(Map.of( "op", "new feature", "magicBoolean", true @@ -205,7 +202,7 @@ void shouldComposeActivityFeatures() throws Exception { singleFeature -> singleFeature .hasId(uuid(newFeature)) .hasActivityLogId(featureId) - .hasAction(Action.UPDATED.toString()) + .hasAction(Action.UPDATE.toString()) .hasReversePatch(""" { "add": 1, @@ -236,7 +233,7 @@ void shouldComposeActivityFeatures() throws Exception { void shouldNotCalculateReversePatchAfterCreation() throws Exception { // Given String featureId = "featureId"; - Guid createdGuid = guid(featureId, Version.now(new JvmInt64(0), Action.CREATED)); + Guid createdGuid = guid(featureId, Version.now(new JvmInt64(0), Action.CREATE)); // And: space storage that returns only some feature with 'CREATE' action configureSpaceStorage( @@ -244,7 +241,7 @@ void shouldNotCalculateReversePatchAfterCreation() throws Exception { nakshaFeature(featureId) .withUuid(createdGuid.toString()) .withPuuid(null) - .withAction(Action.CREATED) + .withAction(Action.CREATE) .build()) ), requestForMissingPredecessorsReturns(emptyList()) @@ -256,7 +253,7 @@ void shouldNotCalculateReversePatchAfterCreation() throws Exception { // Then: result does not bear any reverse patch assertThatResult(result) .hasActivityFeatures(feature -> feature - .hasAction(Action.CREATED.toString()) + .hasAction(Action.CREATE.toString()) .hasId(createdGuid.toString()) .hasActivityLogId(featureId) .hasReversePatch(null) @@ -269,8 +266,8 @@ void shouldNotCalculateDiffAfterDeletion() throws Exception { String featureId = "featureId"; Timestamp ts0 = Timestamp.fromMillis(T0); Timestamp ts1 = Timestamp.fromMillis(T1); - Version createdVersion = Version.auto(ts0.getYear(), ts0.getMonth(), ts0.getDay(), new JvmInt64(0), Action.CREATED); - Version deletedVersion = Version.auto(ts1.getYear(), ts1.getMonth(), ts1.getDay(), new JvmInt64(1), Action.DELETED); + Version createdVersion = Version.auto(ts0.getYear(), ts0.getMonth(), ts0.getDay(), new JvmInt64(0), Action.CREATE); + Version deletedVersion = Version.auto(ts1.getYear(), ts1.getMonth(), ts1.getDay(), new JvmInt64(1), Action.DELETE); Guid createdGuid = guid(featureId, createdVersion); Guid deletedGuid = guid(featureId, deletedVersion); @@ -279,8 +276,8 @@ void shouldNotCalculateDiffAfterDeletion() throws Exception { initialHistoryAwareRequestReturns(List.of( nakshaFeature(featureId) .withUuid(deletedGuid.toString()) - .withNuuid(null) - .withAction(Action.DELETED) + .withPuuid(createdGuid.toString()) + .withAction(Action.DELETE) .withCreatedAt(T0) .withUpdatedAt(T1) .build() @@ -288,8 +285,8 @@ void shouldNotCalculateDiffAfterDeletion() throws Exception { requestForMissingPredecessorsReturns(List.of( nakshaFeature("featureId") .withUuid(createdGuid.toString()) - .withNuuid(deletedGuid.toString()) - .withAction(Action.CREATED) + .withPuuid(null) + .withAction(Action.CREATE) .withCreatedAt(T0) .withUpdatedAt(T0) .build() @@ -303,7 +300,7 @@ void shouldNotCalculateDiffAfterDeletion() throws Exception { assertThatResult(result) .hasActivityFeatures( feature -> feature - .hasAction(Action.DELETED.toString()) + .hasAction(Action.DELETE.toString()) .hasId(deletedGuid.toString()) .hasActivityLogId(featureId) .hasReversePatch(null) @@ -463,15 +460,15 @@ private ArgumentMatcher predecessorRequestMatcher() { private boolean isHistoryAwareReadFeatures(ReadRequest readRequest) { if (readRequest instanceof ReadFeatures rf) { - return rf.getQueryHistory() && rf.getCollectionIds().size() == 1; + return rf.getQueryHistory(); } return false; } private boolean containsNextVersionMetaQuery(ReadFeatures readFeatures, TupleNumber... expectedTns) { - IMetaQuery metaQuery = readFeatures.getQuery().getMetadata(); - if (!(metaQuery instanceof MetaQuery mq)) return false; - boolean basicCheck = mq.getColumn().equals(MetaColumn.nextVersion()) + IMemberQuery metaQuery = readFeatures.getQuery().getMembers(); + if (!(metaQuery instanceof MemberQuery mq)) return false; + boolean basicCheck = ((Proxy)XyzMembers.XyzNextVersion).equals(mq.getMember()) && mq.getOp().equals(AnyOp.IS_ANY_OF); if (!basicCheck) return false; if (expectedTns.length == 0) return mq.getValue() != null; @@ -483,7 +480,7 @@ private boolean containsNextVersionMetaQuery(ReadFeatures readFeatures, TupleNum versions = Arrays.asList(arr); } else if (value instanceof AnyList list) { versions = new java.util.ArrayList<>(); - for (int i = 0; i < list.size(); i++) { + for (int i = 0; i < list.getSize(); i++) { Object item = list.get(i); if (item instanceof Int64 v) versions.add(v); else return false; @@ -493,7 +490,7 @@ private boolean containsNextVersionMetaQuery(ReadFeatures readFeatures, TupleNum } if (versions.size() != expectedTns.length) return false; return Arrays.stream(expectedTns) - .map(tn -> tn.version.txn) + .map(tn -> tn.version) .allMatch(expected -> versions.stream().anyMatch(expected::equals)); } diff --git a/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtilTest.java b/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtilTest.java index 58fe7c799e..52de81934f 100644 --- a/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtilTest.java +++ b/here-naksha-handler-activitylog/src/jvmTest/java/com/here/naksha/handler/activitylog/ActivityLogRequestTranslationUtilTest.java @@ -9,12 +9,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; -import java.util.Random; -import naksha.base.JvmInt64; + import naksha.base.StringList; import naksha.model.Guid; import naksha.model.GuidList; -import naksha.model.TupleNumber; import naksha.model.Version; import naksha.model.request.ReadFeatures; import naksha.model.request.query.IPropertyQuery; @@ -46,7 +44,7 @@ void shouldTranslateSingleGuidPassedAsFeatureId() { // And: there is a single guid passed from original featureId GuidList finalGuids = readFeatures.getGuids(); - assertEquals(1, finalGuids.size()); + assertEquals(1, finalGuids.getSize()); assertEquals(guid, finalGuids.get(0)); } @@ -76,7 +74,7 @@ void shouldTranslateMultipleGuidsPassedAsFeatureIds() { // And: all guids defined in featureIds were moved to ReadFeatures.guids GuidList finalGuids = readFeatures.getGuids(); - assertEquals(guids.size(), finalGuids.size()); + assertEquals(guids.getSize(), finalGuids.getSize()); assertTrue(finalGuids.containsAll(guids)); } @@ -99,7 +97,7 @@ void shouldTranslateActivityLogIdToFeatureId() { // And: there is a single featureId withing the request StringList featureIds = readFeatures.getFeatureIds(); - assertEquals(1, featureIds.size()); + assertEquals(1, featureIds.getSize()); assertEquals(featureId, featureIds.get(0)); // And: @@ -129,7 +127,7 @@ void shouldTranslateActivityLogIdsToFeatureIds() { // And: all ids defined in AcitvityLogNs are now part of featureIds StringList featureIds = readFeatures.getFeatureIds(); - assertEquals(2, featureIds.size()); + assertEquals(2, featureIds.getSize()); assertTrue(featureIds.containsAll(List.of(firstId, secondId))); // And: the pQuery left is effectively dead @@ -161,20 +159,19 @@ void shouldApplyMixedTranslations() { // And: feature ids are populated from ActivityLogNs StringList finalFeatureIds = readFeatures.getFeatureIds(); - assertEquals(1, finalFeatureIds.size()); + assertEquals(1, finalFeatureIds.getSize()); assertEquals(activityLogId, finalFeatureIds.get(0)); // And: guuids are populared from original feature ids GuidList finalGuids = readFeatures.getGuids(); - assertEquals(1, finalGuids.size()); + assertEquals(1, finalGuids.getSize()); assertEquals(guid, finalGuids.get(0)); } private void verifyAllHistoricalVersionsInCollection(ReadFeatures readFeatures) { assertTrue(readFeatures.getQueryHistory()); - StringList collectionIds = readFeatures.getCollectionIds(); - assertEquals(1, collectionIds.size()); - assertEquals(TEST_SPACE_ID, collectionIds.get(0)); + String collectionId = readFeatures.getCollectionId(); + assertEquals(TEST_SPACE_ID, collectionId); assertEquals(Integer.MAX_VALUE, readFeatures.getVersions()); } } \ No newline at end of file diff --git a/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/AnyObject.kt b/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/AnyObject.kt index 5b7276f886..59f3a0e89c 100644 --- a/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/AnyObject.kt +++ b/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/AnyObject.kt @@ -12,4 +12,3 @@ import kotlin.js.JsExport @JsExport open class AnyObject : MapProxy(String::class, Any::class) - diff --git a/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/Proxy.kt b/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/Proxy.kt index e6640e41c3..056d8d47ee 100644 --- a/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/Proxy.kt +++ b/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/Proxy.kt @@ -3,12 +3,16 @@ package naksha.base import naksha.base.Platform.PlatformCompanion.isNil +import naksha.base.PlatformListApi.PlatformListApiCompanion.array_get +import naksha.base.PlatformListApi.PlatformListApiCompanion.array_set +import naksha.base.PlatformMapApi.PlatformMapApiCompanion.map_get +import naksha.base.PlatformMapApi.PlatformMapApiCompanion.map_set import naksha.base.fn.Fn0 import naksha.base.fn.Fn1 import kotlin.js.JsExport +import kotlin.js.JsName import kotlin.js.JsStatic import kotlin.jvm.JvmStatic -import kotlin.jvm.JvmSuppressWildcards import kotlin.reflect.KClass /** @@ -228,4 +232,347 @@ abstract class Proxy : PlatformObject { */ fun contentDeepEquals(other: Proxy): Boolean = PlatformUtil.deepEquals(this, other) -} + /** + * Get the property from the given path. + * + * @param path the JSON path to query. + * @return the value at the path or `null`, if the path does not exist or the value is actually `null`. + * @since 3.0 + */ + @JsName("getPathByList") + fun getPath(path: List): Any? { + var current: Any? = this.platformObject() + for (key in path) { + if (key is String) { + if (current !is PlatformMap) return null + current = map_get(current, key) + continue + } + if (key is Number) { + if (current !is PlatformList) return null + val index = key.toInt() + current = array_get(current, index) + continue + } + return null + } + if (current is PlatformMap) return Platform.proxy(current, AnyObject::class) + if (current is PlatformList) return Platform.proxy(current, AnyList::class) + if (current is PlatformDataView) return Platform.proxy(current, DataViewProxy::class) + return current + } + + /** + * Set the property at the given path. Creates the path, if it does not exist yet. Throws an [RuntimeException] if the path exists, but is of wrong type, for example an array is expected, but an object found or vice versa. + * + * @param value the value to set. + * @param path the JSON path to mutate. + * @return the previous value. + * @since 3.0 + */ + @JsName("setPathByList") + fun setPath(value: Any?, path: List): Any? { + val pathEnd = path.size + val pathLast = pathEnd - 1 + var current: Any = this.platformObject() + for (i in 0 until pathLast) { + val key = path[i] + if (key is String) { + if (current !is PlatformMap) throw RuntimeException("Invalid value at key '$key', expected object") + // --- current is PlatformMap --- + val value = map_get(current, key) + if (value == null) { + // The key does not exist, check if we should create a map or list. + val next_key = path[i + 1] + if (next_key is String) { + val new_map = Platform.newMap() + map_set(current, key, new_map) + current = new_map + continue + } + if (next_key is Number) { + val new_list = Platform.newList() + map_set(current, key, new_list) + current = new_list + continue + } + // The next key is invalid + throw RuntimeException("Invalid key in path: '$next_key' at position ${i+1}") + } + current = value + continue + } + if (key is Number) { + if (current !is PlatformList) throw RuntimeException("Invalid value at key '$key', expected array") + // --- current is PlatformList --- + val index: Int = key.toInt() + val value = array_get(current, index) + if (value == null) { + // The index does not exist, check if we should create a map or list. + val next_key = path[i + 1] + if (next_key is String) { + val new_map = Platform.newMap() + array_set(current, index, new_map) + current = new_map + continue + } + if (next_key is Number) { + val new_list = Platform.newList() + array_set(current, index, new_list) + current = new_list + continue + } + // The next key is invalid + throw RuntimeException("Invalid key in path: '$next_key' at position ${i+1}") + } + current = value + continue + } + throw RuntimeException("Invalid key in path: '$key' at position $i") + } + val key = path[pathLast] + if (key is String) { + if (current !is PlatformMap) throw RuntimeException("Invalid value at key '$key', expected object") + val oldValue = map_get(current, key) + map_set(current, key, value) + return oldValue + } + if (key is Number) { + if (current !is PlatformList) throw RuntimeException("Invalid value at key '$key', expected array") + val index = key.toInt() + val oldValue = array_get(current, index) + array_set(current, index, value) + return oldValue + } + throw RuntimeException("Invalid key in path: '$key' at position $pathLast") + } + + /** + * Get the property from the given path. + * + * @param path the JSON path to query. + * @return the value at the path or `null`, if the path does not exist or the value is actually `null`. + * @since 3.0 + */ + @JsName("getPathByArray") + fun getPath(path: Array, length: Int = path.size): Any? { + var current: Any? = this.platformObject() + for (i in 0 until length) { + if (i >= path.size) return null + val key = path[i] + if (key is String) { + if (current !is PlatformMap) return null + current = map_get(current, key) + continue + } + if (key is Number) { + if (current !is PlatformList) return null + val index = key.toInt() + current = array_get(current, index) + continue + } + return null + } + if (current is PlatformMap) return Platform.proxy(current, AnyObject::class) + if (current is PlatformList) return Platform.proxy(current, AnyList::class) + if (current is PlatformDataView) return Platform.proxy(current, DataViewProxy::class) + return current + } + + /** + * Set the property at the given path. Creates the path, if it does not exist yet. Throws an [RuntimeException] if the path exists, but is of wrong type, for example an array is expected, but an object found or vice versa. + * + * @param value the value to set. + * @param path the JSON path to mutate. + * @return the previous value. + * @since 3.0 + */ + @JsName("setPathByArray") + fun setPath(value: Any?, path: Array, pathEnd: Int = path.size): Any? { + val pathLast = pathEnd - 1 + var current: Any = this.platformObject() + for (i in 0 until pathLast) { + val key = path[i] + if (key is String) { + if (current !is PlatformMap) throw RuntimeException("Invalid value at key '$key', expected object") + // --- current is PlatformMap --- + val value = map_get(current, key) + if (value == null) { + // The key does not exist, check if we should create a map or list. + val next_key = path[i + 1] + if (next_key is String) { + val new_map = Platform.newMap() + map_set(current, key, new_map) + current = new_map + continue + } + if (next_key is Number) { + val new_list = Platform.newList() + map_set(current, key, new_list) + current = new_list + continue + } + // The next key is invalid + throw RuntimeException("Invalid key in path: '$next_key' at position ${i+1}") + } + current = value + continue + } + if (key is Number) { + if (current !is PlatformList) throw RuntimeException("Invalid value at key '$key', expected array") + // --- current is PlatformList --- + val index: Int = key.toInt() + val value = array_get(current, index) + if (value == null) { + // The index does not exist, check if we should create a map or list. + val next_key = path[i + 1] + if (next_key is String) { + val new_map = Platform.newMap() + array_set(current, index, new_map) + current = new_map + continue + } + if (next_key is Number) { + val new_list = Platform.newList() + array_set(current, index, new_list) + current = new_list + continue + } + // The next key is invalid + throw RuntimeException("Invalid key in path: '$next_key' at position ${i+1}") + } + current = value + continue + } + throw RuntimeException("Invalid key in path: '$key' at position $i") + } + val key = path[pathLast] + if (key is String) { + if (current !is PlatformMap) throw RuntimeException("Invalid value at key '$key', expected object") + val oldValue = map_get(current, key) + map_set(current, key, value) + return oldValue + } + if (key is Number) { + if (current !is PlatformList) throw RuntimeException("Invalid value at key '$key', expected array") + val index = key.toInt() + val oldValue = array_get(current, index) + array_set(current, index, value) + return oldValue + } + throw RuntimeException("Invalid key in path: '$key' at position $pathLast") + } + + /** + * Get the property from the given path. + * + * @param path the JSON path to query. + * @return the value at the path or `null`, if the path does not exist or the value is actually `null`. + * @since 3.0 + */ + fun getPath(vararg path: Any): Any? { + var current: Any? = this.platformObject() + for (key in path) { + if (key is String) { + if (current !is PlatformMap) return null + current = map_get(current, key) + continue + } + if (key is Number) { + if (current !is PlatformList) return null + val index = key.toInt() + current = array_get(current, index) + continue + } + return null + } + if (current is PlatformMap) return Platform.proxy(current, AnyObject::class) + if (current is PlatformList) return Platform.proxy(current, AnyList::class) + if (current is PlatformDataView) return Platform.proxy(current, DataViewProxy::class) + return current + } + + /** + * Set the property at the given path. Creates the path, if it does not exist yet. Throws an [RuntimeException] if the path exists, but is of wrong type, for example an array is expected, but an object found or vice versa. + * + * @param value the value to set. + * @param path the JSON path to mutate. + * @return the previous value. + * @since 3.0 + */ + fun setPath(value: Any?, vararg path: Any): Any? { + val pathEnd = path.size + val pathLast = pathEnd - 1 + var current: Any = this.platformObject() + for (i in 0 until pathLast) { + val key = path[i] + if (key is String) { + if (current !is PlatformMap) throw RuntimeException("Invalid value at key '$key', expected object") + // --- current is PlatformMap --- + val value = map_get(current, key) + if (value == null) { + // The key does not exist, check if we should create a map or list. + val next_key = path[i + 1] + if (next_key is String) { + val new_map = Platform.newMap() + map_set(current, key, new_map) + current = new_map + continue + } + if (next_key is Number) { + val new_list = Platform.newList() + map_set(current, key, new_list) + current = new_list + continue + } + // The next key is invalid + throw RuntimeException("Invalid key in path: '$next_key' at position ${i+1}") + } + current = value + continue + } + if (key is Number) { + if (current !is PlatformList) throw RuntimeException("Invalid value at key '$key', expected array") + // --- current is PlatformList --- + val index: Int = key.toInt() + val value = array_get(current, index) + if (value == null) { + // The index does not exist, check if we should create a map or list. + val next_key = path[i + 1] + if (next_key is String) { + val new_map = Platform.newMap() + array_set(current, index, new_map) + current = new_map + continue + } + if (next_key is Number) { + val new_list = Platform.newList() + array_set(current, index, new_list) + current = new_list + continue + } + // The next key is invalid + throw RuntimeException("Invalid key in path: '$next_key' at position ${i+1}") + } + current = value + continue + } + throw RuntimeException("Invalid key in path: '$key' at position $i") + } + val key = path[pathLast] + if (key is String) { + if (current !is PlatformMap) throw RuntimeException("Invalid value at key '$key', expected object") + val oldValue = map_get(current, key) + map_set(current, key, value) + return oldValue + } + if (key is Number) { + if (current !is PlatformList) throw RuntimeException("Invalid value at key '$key', expected array") + val index = key.toInt() + val oldValue = array_get(current, index) + array_set(current, index, value) + return oldValue + } + throw RuntimeException("Invalid key in path: '$key' at position $pathLast") + } +} \ No newline at end of file diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/ContextXyzFeatureResponse.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/ContextXyzFeatureResponse.java index dd4f9382b4..2c30292885 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/ContextXyzFeatureResponse.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/ContextXyzFeatureResponse.java @@ -44,7 +44,7 @@ public void setFeatures(@NotNull List nakshaFeatures) { */ @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) public @Nullable List getContext() { - return JvmBoxingUtil.box(get(CONTEXT_KEY), NakshaFeatureList.class); + return JvmBoxingUtil.box(getPath(CONTEXT_KEY), NakshaFeatureList.class); } @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) @@ -66,7 +66,7 @@ public void setContext(@Nullable NakshaFeatureList contextFeatures) { */ @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) public @Nullable List getViolations() { - return JvmBoxingUtil.box(get(VIOLATIONS_KEY), NakshaFeatureList.class); + return JvmBoxingUtil.box(getPath(VIOLATIONS_KEY), NakshaFeatureList.class); } @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/geojson/implementation/namespaces/XyzActivityLog.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/geojson/implementation/namespaces/XyzActivityLog.java index dcd976d90e..ae423d4027 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/geojson/implementation/namespaces/XyzActivityLog.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/geojson/implementation/namespaces/XyzActivityLog.java @@ -97,12 +97,12 @@ public void setAction(@NotNull Action action) { } public boolean isDeleted() { - return Action.DELETED.toString().equals(getAction()); + return Action.DELETE.toString().equals(getAction()); } public void setDeleted(boolean deleted) { if (deleted) { - setAction(Action.DELETED); + setAction(Action.DELETE); } } diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintAll.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintAll.java index dba7c74b83..12478fb92e 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintAll.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintAll.java @@ -29,7 +29,7 @@ public class ConstraintAll extends Constraint { * The constraints that all need to hold true (AND). */ public List getOf() { - return JvmBoxingUtil.box(get(OF), ConstraintList.class); + return JvmBoxingUtil.box(getPath(OF), ConstraintList.class); } public void setOf(List of) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintCheck.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintCheck.java index fdddeca953..1a22edc7fd 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintCheck.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintCheck.java @@ -66,7 +66,7 @@ public enum Test { /** The check to perform. */ public Test getTest() { - return JvmBoxingUtil.box(get(TEST), Test.class); + return JvmBoxingUtil.box(getPath(TEST), Test.class); } public void setTest(Test test) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintNot.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintNot.java index 9fb42aa82a..f3cf762032 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintNot.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintNot.java @@ -29,7 +29,7 @@ public class ConstraintNot extends Constraint { * The constraints that should be negated. */ public List getOf() { - return JvmBoxingUtil.box(get(OF), ConstraintList.class); + return JvmBoxingUtil.box(getPath(OF), ConstraintList.class); } public void setOf(List of) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintOne.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintOne.java index 96f87a3d91..e3052e169d 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintOne.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/ConstraintOne.java @@ -29,7 +29,7 @@ public class ConstraintOne extends Constraint { * The constraints of which at least one need to hold true (OR). */ public List getOf() { - return JvmBoxingUtil.box(get(OF), ConstraintList.class); + return JvmBoxingUtil.box(getPath(OF), ConstraintList.class); } public void setOf(List of) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/Index.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/Index.java index 0ba5c39119..2463a506f0 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/Index.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/Index.java @@ -63,7 +63,7 @@ public void setIndexHistory(boolean indexHistory) { /** All properties that should be included in this index. */ public List getIndexProperties() { - return JvmBoxingUtil.box(getProperties().get(NESTED_INDEX_PROPS), IndexProperties.class); + return JvmBoxingUtil.box(getProperties().getPath(NESTED_INDEX_PROPS), IndexProperties.class); } public void setProperties(List properties) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/IndexProperty.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/IndexProperty.java index 06e50adc3b..33989f8701 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/IndexProperty.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/indexing/IndexProperty.java @@ -54,7 +54,7 @@ public void setAsc(boolean asc) { * Optionally decide if {@code null} values should be ordered first or last. If not explicitly defined, automatically decided. */ public Nulls getNulls() { - return JvmBoxingUtil.box(get(NULLS), Nulls.class); + return JvmBoxingUtil.box(getPath(NULLS), Nulls.class); } public void setNulls(Nulls nulls) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/Space.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/Space.java index ec9e27f92c..eb7c7b9a70 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/Space.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/Space.java @@ -116,7 +116,7 @@ public void setShared(final boolean shared) { * Copyright information for the data in the space. */ public List getCopyright() { - return JvmBoxingUtil.box(get(COPYRIGHT), Copyright.List.class); + return JvmBoxingUtil.box(getPath(COPYRIGHT), Copyright.List.class); } public void setCopyright(final List copyright) { @@ -134,7 +134,7 @@ public void setCopyright(final List copyright) { * Information about the license bound to the data within the space. For valid keywords see {@link License}. */ public License getLicense() { - return JvmBoxingUtil.box(get(LICENSE), License.class); + return JvmBoxingUtil.box(getPath(LICENSE), License.class); } public void setLicense(final License license) { @@ -150,7 +150,7 @@ public void setLicense(final License license) { * List of packages that this space belongs to. */ public List<@NotNull String> getPackages() { - return JvmBoxingUtil.box(get(PACKAGES), StringList.class); + return JvmBoxingUtil.box(getPath(PACKAGES), StringList.class); } public void setPackages(final List<@NotNull String> packages) { @@ -178,7 +178,7 @@ public void setReadOnly(final boolean readOnly) { * but the result can be bad. */ public @Nullable Map<@NotNull String, @NotNull Index> getIndices() { - return JvmBoxingUtil.box(get(INDICES), Index.Map.class); + return JvmBoxingUtil.box(getPath(INDICES), Index.Map.class); } public void setIndices(@Nullable Map<@NotNull String, @NotNull Index> indices) { @@ -192,7 +192,7 @@ public void setIndices(@Nullable Map<@NotNull String, @NotNull Index> indices) { * will fail, if the space does not fulfill the constraint. */ public @Nullable Map<@NotNull String, @NotNull Constraint> getConstraints() { - return JvmBoxingUtil.box(get(CONSTRAINTS), ConstraintMap.class); + return JvmBoxingUtil.box(getPath(CONSTRAINTS), ConstraintMap.class); } public void setConstraints(@Nullable Map<@NotNull String, @NotNull Constraint> constraints) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/SpaceProperties.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/SpaceProperties.java index e85b31818a..04ca29c155 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/SpaceProperties.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/naksha/SpaceProperties.java @@ -38,7 +38,7 @@ public class SpaceProperties extends NakshaProperties { * The backend storage collection details specified at space level */ public @Nullable NakshaCollection getCollection() { - return JvmBoxingUtil.box(get(NAKSHA_COLLECTION), NakshaCollection.class); + return JvmBoxingUtil.box(getPath(NAKSHA_COLLECTION), NakshaCollection.class); } public void setCollection(final @Nullable NakshaCollection collection) { diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ContextWriteFeatures.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ContextWriteFeatures.java index cff15668f6..06079d47fa 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ContextWriteFeatures.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ContextWriteFeatures.java @@ -43,7 +43,7 @@ public abstract class ContextWriteFeatures extends WriteRequest { */ @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) public @Nullable List getContext() { - return JvmBoxingUtil.box(get(CONTEXT_KEY), NakshaFeatureList.class); + return JvmBoxingUtil.box(getPath(CONTEXT_KEY), NakshaFeatureList.class); } @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) @@ -65,7 +65,7 @@ public void setContext(@Nullable NakshaFeatureList contextFeatures) { */ @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) public @Nullable List getViolations() { - return JvmBoxingUtil.box(get(VIOLATIONS_KEY), NakshaFeatureList.class); + return JvmBoxingUtil.box(getPath(VIOLATIONS_KEY), NakshaFeatureList.class); } @ApiStatus.AvailableSince(NakshaVersion.v2_0_11) diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ReadFeaturesProxyWrapper.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ReadFeaturesProxyWrapper.java index f42944f78c..58a4e96bed 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ReadFeaturesProxyWrapper.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/lib/core/models/storage/ReadFeaturesProxyWrapper.java @@ -27,7 +27,6 @@ import naksha.model.request.query.ITagQuery; import org.jetbrains.annotations.NotNull; -import java.util.HashMap; import java.util.Map; @@ -69,7 +68,7 @@ public ReadFeaturesProxyWrapper withFeatureIds(StringList featureIds){ } public Map getQueryParameters() { - return JvmBoxingUtil.box(get(QUERY_PARAMETERS), QueryParameterMap.class); + return JvmBoxingUtil.box(getPath(QUERY_PARAMETERS), QueryParameterMap.class); } public T getQueryParameter(String key) throws ClassCastException { @@ -107,7 +106,7 @@ public ReadFeaturesProxyWrapper withLimit(int limit){ } public ReadFeaturesProxyWrapper withCollection(String collectionId){ - getCollectionIds().add(collectionId); + setCollectionId(collectionId); return this; } diff --git a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/test/common/assertions/PropertyQueryAssertions.java b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/test/common/assertions/PropertyQueryAssertions.java index a970773b0b..91c6667208 100644 --- a/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/test/common/assertions/PropertyQueryAssertions.java +++ b/here-naksha-lib-core/src/jvmMain/java/com/here/naksha/test/common/assertions/PropertyQueryAssertions.java @@ -62,7 +62,11 @@ public PropertyQueryAssertions isPQuery(){ } public PropertyQueryAssertions hasProperty(Property expected) { - return hasProperty(expected.getPath()); + java.util.List path = new java.util.ArrayList<>(); + for (Object segment : expected.getPath().asList()) { + path.add(segment.toString()); + } + return hasProperty(path); } public PropertyQueryAssertions hasProperty(List expected) { diff --git a/here-naksha-lib-core/src/jvmTest/java/com/here/naksha/lib/core/JsonMappingTest.java b/here-naksha-lib-core/src/jvmTest/java/com/here/naksha/lib/core/JsonMappingTest.java index 2f19bc6f4f..68ecb184ef 100644 --- a/here-naksha-lib-core/src/jvmTest/java/com/here/naksha/lib/core/JsonMappingTest.java +++ b/here-naksha-lib-core/src/jvmTest/java/com/here/naksha/lib/core/JsonMappingTest.java @@ -25,12 +25,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.here.naksha.lib.core.util.json.Json; -import com.here.naksha.lib.core.util.json.JsonSerializable; + import java.io.IOException; import naksha.base.*; -import naksha.model.NakshaError; import naksha.model.objects.NakshaFeature; import org.junit.jupiter.api.Test; @@ -45,8 +43,8 @@ public void testDeserializeFeature() { final NakshaFeature obj = jvmMap.proxy(Platform.klassFor(NakshaFeature.class)); assertNotNull(obj); - assertEquals(5, (int) obj.getProperties().get("x")); - assertEquals("123", obj.get("otherProperty")); + assertEquals(5, (int) obj.getProperties().getPath("x")); + assertEquals("123", obj.getPath("otherProperty")); } @Test diff --git a/here-naksha-lib-ext-manager/src/jvmTest/java/com/here/naksha/lib/extmanager/ExtensionCacheTest.java b/here-naksha-lib-ext-manager/src/jvmTest/java/com/here/naksha/lib/extmanager/ExtensionCacheTest.java index 6824ca3123..a140c6a0a8 100644 --- a/here-naksha-lib-ext-manager/src/jvmTest/java/com/here/naksha/lib/extmanager/ExtensionCacheTest.java +++ b/here-naksha-lib-ext-manager/src/jvmTest/java/com/here/naksha/lib/extmanager/ExtensionCacheTest.java @@ -154,7 +154,7 @@ public void testGetCachedExtensionsVersionUpdate() throws IOException { Assertions.assertEquals(1, newCached.size()); Extension updatedExt = newCached.get(0); - Assertions.assertEquals("child_extension_1", updatedExt.get("extensionId")); + Assertions.assertEquals("child_extension_1", updatedExt.getPath("extensionId")); Assertions.assertEquals("2.0", updatedExt.getVersion()); String ext1KeyNew = updatedExt.getEnv() + ":" + updatedExt.getId(); diff --git a/here-naksha-lib-geo/src/commonMain/kotlin/naksha/geo/SpType.kt b/here-naksha-lib-geo/src/commonMain/kotlin/naksha/geo/SpType.kt index caa8bd1e1a..25fbefddac 100644 --- a/here-naksha-lib-geo/src/commonMain/kotlin/naksha/geo/SpType.kt +++ b/here-naksha-lib-geo/src/commonMain/kotlin/naksha/geo/SpType.kt @@ -29,33 +29,42 @@ class SpType : JsEnum() { @JsStatic fun of(value: String): SpType = get(value, SpType::class) + /** + * Returns the given value as [SpType]. + * @param value the value. + * @return the [SpType] representing this value; `null` if the value does not represent any [SpGeometry]. + */ + @JvmStatic + @JsStatic + fun ofDefined(value: String?): SpType? = if (value != null) getDefined(value, SpType::class) else null + @JvmField @JsStatic - val Point = def(SpType::class, "Point") + val Point = def(SpType::class, "Point") { self -> self._klass = SpPoint::class } @JvmField @JsStatic - val MultiPoint = def(SpType::class, "MultiPoint") + val MultiPoint = def(SpType::class, "MultiPoint") { self -> self._klass = SpMultiPoint::class } @JvmField @JsStatic - val LineString = def(SpType::class, "LineString") + val LineString = def(SpType::class, "LineString") { self -> self._klass = SpLineString::class } @JvmField @JsStatic - val MultiLineString = def(SpType::class, "MultiLineString") + val MultiLineString = def(SpType::class, "MultiLineString") { self -> self._klass = SpMultiLineString::class } @JvmField @JsStatic - val Polygon = def(SpType::class, "Polygon") + val Polygon = def(SpType::class, "Polygon") { self -> self._klass = SpPolygon::class } @JvmField @JsStatic - val MultiPolygon = def(SpType::class, "MultiPolygon") + val MultiPolygon = def(SpType::class, "MultiPolygon") { self -> self._klass = SpMultiPolygon::class } @JvmField @JsStatic - val GeometryCollection = def(SpType::class, "GeometryCollection") + val GeometryCollection = def(SpType::class, "GeometryCollection") { self -> self._klass = SpGeometryCollection::class } } /** @@ -69,4 +78,12 @@ class SpType : JsEnum() { if (any !is AnyObject) return false return any.getRaw("type") == typeName } + + private var _klass: KClass? = null + + /** + * The [KClass] referring to the [SpGeometry] type; access throws an exception, when this not [isDefined]. + */ + val klass: KClass + get() = _klass!! } \ No newline at end of file diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandler.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandler.java index ce16f058bb..06ad4cc8a9 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandler.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandler.java @@ -36,7 +36,7 @@ import naksha.model.SessionOptions; import naksha.model.StreamInfo; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.objects.NakshaStorage; import naksha.model.request.ErrorResponse; import naksha.model.request.ReadFeatures; @@ -455,7 +455,7 @@ private Response retryDueToUninitializedStorage( @NotNull SessionOptions sessionOptions, @NotNull IStorage storageImpl, @NotNull String mapId) { - WriteRequest createMapRequest = new WriteRequest().add(new Write().createMap(new NakshaMap(mapId))); + WriteRequest createMapRequest = new WriteRequest().add(new Write().createMap(new NakshaCatalog(mapId))); return singleWrite(sessionOptions, storageImpl, createMapRequest); } @@ -525,19 +525,19 @@ private void applyMapIdAndCollectionId( ) { if (request instanceof ReadFeatures) { ReadFeatures rf = (ReadFeatures) request; - rf.setMapId(mapId); - rf.setCollectionIds(StringList.of(collectionId)); + rf.setCatalogId(mapId); + rf.setCollectionId(collectionId); } else if (request instanceof WriteRequest) { WriteRequest wr = (WriteRequest) request; if (isOnlyWriteCollections(wr)) { collectionsFrom(wr).forEach(collectionFromRequest -> { - collectionFromRequest.setMapId(mapId); + collectionFromRequest.setCatalogId(mapId); collectionFromRequest.setId(collectionId); }); } - String finalCollectionId = isOnlyWriteCollections(wr) ? Naksha.COLLECTIONS_COL : collectionId; + String finalCollectionId = isOnlyWriteCollections(wr) ? Naksha.COLLECTIONS_COL_ID : collectionId; wr.getWrites().forEach(write -> { - write.setMapId(mapId); + write.setCatalogId(mapId); write.setCollectionId(finalCollectionId); }); } diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandlerProperties.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandlerProperties.java index 2a2e34d150..5c062444e5 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandlerProperties.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultStorageHandlerProperties.java @@ -64,7 +64,7 @@ public void setStorageId(final @Nullable String storageId) { * Details of the backend xyz collection to use. If undefined, the collection defined at the {@link SpaceProperties} level will be used. */ public @Nullable NakshaCollection getCollection() { - return JvmBoxingUtil.box(get(COLLECTION), NakshaCollection.class); + return JvmBoxingUtil.box(getPath(COLLECTION), NakshaCollection.class); } public void setCollection(final @Nullable NakshaCollection collection) { diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultViewHandlerProperties.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultViewHandlerProperties.java index 8f773d9c9e..0df2115550 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultViewHandlerProperties.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/DefaultViewHandlerProperties.java @@ -51,7 +51,7 @@ public void setStorageId(@Nullable String storageId) { } public @Nullable List getSpaceIds() { - return JvmBoxingUtil.box(get(SPACE_IDS), StringList.class); + return JvmBoxingUtil.box(getPath(SPACE_IDS), StringList.class); } public void setSpaceIds(@Nullable List spaceIds) { diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/SourceIdHandler.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/SourceIdHandler.java index d3b23fddf6..ba7b93d677 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/SourceIdHandler.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/SourceIdHandler.java @@ -204,8 +204,8 @@ else if ((propertyComponent instanceof PAnd) && (((PAnd) propertyComponent).size } private static boolean propertyReferenceEqualsSourceId(Property pRef) { - List<@NotNull String> path = pRef.getPath(); - return path.size() == PREF_PATHS_SIZE && path.containsAll(List.of(NakshaProperties.META_KEY, SOURCE_ID)); + naksha.model.objects.JsonPath jp = pRef.getPath(); + return jp.size() == PREF_PATHS_SIZE && jp.asList().containsAll(List.of(NakshaProperties.META_KEY, SOURCE_ID)); } private static boolean sourceIdTransformationCapable(PQuery propertyOperation) { diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/TagFilterHandlerProperties.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/TagFilterHandlerProperties.java index b3a1213b18..ca0f962ec6 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/TagFilterHandlerProperties.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/TagFilterHandlerProperties.java @@ -46,7 +46,7 @@ public class TagFilterHandlerProperties extends NakshaProperties { * {@link naksha.model.request.WriteRequest} operations. */ public @Nullable List getAdd() { - return JvmBoxingUtil.box(get(ADD_VALUES), StringList.class); + return JvmBoxingUtil.box(getPath(ADD_VALUES), StringList.class); } public void setAdd(@Nullable final List add) { @@ -58,7 +58,7 @@ public void setAdd(@Nullable final List add) { * {@link naksha.model.request.WriteRequest} operations. This is applied before {@link #getAdd()} operation. */ public @Nullable List getRemoveWithPrefixes() { - return JvmBoxingUtil.box(get(REMOVE_W_PREFIXES), StringList.class); + return JvmBoxingUtil.box(getPath(REMOVE_W_PREFIXES), StringList.class); } public void setRemoveWithPrefixes(final @Nullable List removeWithPrefixes) { @@ -70,7 +70,7 @@ public void setRemoveWithPrefixes(final @Nullable List removeWithPrefixe * handler. */ public @Nullable List getContains() { - return JvmBoxingUtil.box(get(CONTAINS_VALUES), StringList.class); + return JvmBoxingUtil.box(getPath(CONTAINS_VALUES), StringList.class); } public void setContains(@Nullable List contains) { diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForEventHandlerConfigs.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForEventHandlerConfigs.java index 96546245d3..de2d652cec 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForEventHandlerConfigs.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForEventHandlerConfigs.java @@ -215,7 +215,7 @@ private Response spaceExistenceValidation(List spaceIds) { private @NotNull Response storageValidation( @NotNull EventHandlerConfig eventHandler, @NotNull String storagePropertyName) { - Object storageIdProp = eventHandler.getProperties().get(storagePropertyName); + Object storageIdProp = eventHandler.getProperties().getPath(storagePropertyName); if (storageIdProp == null) { return new ErrorResponse( NakshaError.ILLEGAL_ARGUMENT, @@ -261,8 +261,8 @@ private Response noActiveSpaceValidation(Write codec) { // Scan through all spaces with JSON property "eventHandlerIds" containing the targeted handler ID final Property pRef = new Property(EVENT_HANDLER_IDS); final PQuery activeSpacesPOp = new PQuery(pRef, AnyOp.CONTAINS, handlerId); - final ReadFeatures readActiveHandlersRequest = new ReadFeatures().addCollectionId(SPACES) - .withMapId(nakshaHub.getAdminMapId()) + final ReadFeatures readActiveHandlersRequest = new ReadFeatures().withCollectionId(SPACES) + .withCatalogId(nakshaHub.getAdminMapId()) .withPropertyQuery(activeSpacesPOp); return nakshaHub().getAdminStorage().useReadSession(new SessionOptions(), readSession -> { diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForStorageConfigs.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForStorageConfigs.java index bfb42c3702..9032b2899d 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForStorageConfigs.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/internal/IntHandlerForStorageConfigs.java @@ -108,8 +108,8 @@ private Response noActiveHandlerValidation(Write codec) { final Property property = new Property(NakshaFeature.PROPERTIES_KEY, DefaultStorageHandlerProperties.STORAGE_ID); final PQuery activeHandlersPOp = new PQuery(property, StringOp.EQUALS, storageId); - final ReadFeatures readActiveHandlersRequest = new ReadFeatures().addCollectionId(EVENT_HANDLERS) - .withMapId(nakshaHub.getAdminMapId()) + final ReadFeatures readActiveHandlersRequest = new ReadFeatures().withCollectionId(EVENT_HANDLERS) + .withCatalogId(nakshaHub.getAdminMapId()) .withPropertyQuery(activeHandlersPOp); Response activeHandlersResponse = nakshaHub().getAdminStorage() .useReadSession(SessionOptions.from(NakshaContext.currentContext()), readSession -> readSession.execute(readActiveHandlersRequest)); diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/HandlerUtil.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/HandlerUtil.java index 43a98b96e3..4f7f0f3cfe 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/HandlerUtil.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/HandlerUtil.java @@ -51,7 +51,7 @@ private HandlerUtil() {} final @Nullable List violations) { for (final NakshaFeature feature : features) { - feature.getProperties().getXyz().setRaw(XyzNs.ACTION, Action.UPDATED); + feature.getProperties().getXyz().setRaw(XyzNs.ACTION, Action.UPDATE); } // Create ContextResult with cursor, context and violations final ContextXyzFeatureResponse ctxResult = new ContextXyzFeatureResponse(); diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/RequestTypesUtil.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/RequestTypesUtil.java index ed42b8899c..19b6b476b0 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/RequestTypesUtil.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/util/RequestTypesUtil.java @@ -38,7 +38,7 @@ public static boolean isOnlyWriteFeatures(@NotNull Request request) { for (Write write : ((WriteRequest) request).getWrites()) { // A Write operation onto the virtual "naksha~collections" means that it is a write request for // NakshaCollection - if (Naksha.COLLECTIONS_COL.equals(write.getCollectionId())) return false; + if (Naksha.COLLECTIONS_COL_ID.equals(write.getCollectionId())) return false; } return true; } @@ -53,7 +53,7 @@ public static boolean isOnlyWriteCollections(Request request) { for (Write write : writes) { // A Write operation onto the virtual "naksha~collections" means that it is a write request for // NakshaCollection - if (!Naksha.COLLECTIONS_COL.equals(write.getCollectionId())) return false; + if (!Naksha.COLLECTIONS_COL_ID.equals(write.getCollectionId())) return false; } return true; } diff --git a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/val/MockValidationHandler.java b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/val/MockValidationHandler.java index 6530c73f0c..821ea21dfe 100644 --- a/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/val/MockValidationHandler.java +++ b/here-naksha-lib-handlers/src/jvmMain/java/com/here/naksha/lib/handlers/val/MockValidationHandler.java @@ -127,7 +127,7 @@ protected EventProcessingStrategy processingStrategyFor(IEvent event) { // feature #3, will have 3 violations i.e. min(3,3) // feature #4, will have 3 violations i.e. min(4,3) int violationsCount = Math.min(featureCnt, totalViolations); - final Object momType = feature.get("momType"); + final Object momType = feature.getPath("momType"); violations.addAll(getNViolationsWithFeatureReference( violationsCount, feature, diff --git a/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/DefaultStorageHandlerTest.java b/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/DefaultStorageHandlerTest.java index 801ab0ecd2..3e9df52f9a 100644 --- a/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/DefaultStorageHandlerTest.java +++ b/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/DefaultStorageHandlerTest.java @@ -180,7 +180,7 @@ void shouldCreateMissingCollectionRespectingPriority(CollectionPriorityTestCase Write writeCollection = findSingleCreateCollectionWrite(capturedWrites); assertEquals(WriteOp.CREATE, writeCollection.getOp()); assertEquals(testCase.correctCollection().getId(), writeCollection.getId()); - assertEquals(Naksha.COLLECTIONS_COL, writeCollection.getCollectionId()); + assertEquals(Naksha.COLLECTIONS_COL_ID, writeCollection.getCollectionId()); // And: write features related to the same feature in correct collection List featureWrites = getSingularWritesToCollection(capturedWrites, testCase.correctCollection().getId()); @@ -321,8 +321,8 @@ void shouldCreateMapIfMissing() { assertEquals(1, secondRequestWrites.size()); Write mapWrite = secondRequestWrites.get(0); assertEquals(WriteOp.CREATE, mapWrite.getOp()); - assertEquals(Naksha.ADMIN_MAP, mapWrite.getMapId()); - assertEquals(Naksha.CATALOGS_COL, mapWrite.getCollectionId()); + assertEquals(Naksha.ADMIN_CATALOG_ID, mapWrite.getCatalogId()); + assertEquals(Naksha.CATALOGS_COL_ID, mapWrite.getCollectionId()); assertEquals(mapId, mapWrite.getId()); } @@ -354,7 +354,7 @@ void shouldUseMapFromStorageProps() { assertEquals(1, subsmittedWrites.size()); Write submittedWrite = subsmittedWrites.get(0); assertEquals(WriteOp.CREATE, submittedWrite.getOp()); - assertEquals(mapIdFromStorageProps, submittedWrite.getMapId()); + assertEquals(mapIdFromStorageProps, submittedWrite.getCatalogId()); assertEquals(handler.properties.getCollection().getId(), submittedWrite.getCollectionId()); } @@ -393,18 +393,18 @@ void shouldReattemptWriteCollectionsAfterMissingMapByCreatingMap() { // 1st and 3rd are WriteCollections against COLLECTIONS_COL with map from storage props assertTrue(RequestTypesUtil.isOnlyWriteCollections(calls.get(0))); assertTrue(RequestTypesUtil.isOnlyWriteCollections(calls.get(2))); - assertEquals(Naksha.COLLECTIONS_COL, calls.get(0).getWrites().get(0).getCollectionId()); - assertEquals(mapIdFromStorageProps, calls.get(0).getWrites().get(0).getMapId()); + assertEquals(Naksha.COLLECTIONS_COL_ID, calls.get(0).getWrites().get(0).getCollectionId()); + assertEquals(mapIdFromStorageProps, calls.get(0).getWrites().get(0).getCatalogId()); assertEquals("target_collection", calls.get(0).getWrites().get(0).getFeature().getId()); - assertEquals(Naksha.COLLECTIONS_COL, calls.get(2).getWrites().get(0).getCollectionId()); - assertEquals(mapIdFromStorageProps, calls.get(2).getWrites().get(0).getMapId()); + assertEquals(Naksha.COLLECTIONS_COL_ID, calls.get(2).getWrites().get(0).getCollectionId()); + assertEquals(mapIdFromStorageProps, calls.get(2).getWrites().get(0).getCatalogId()); assertEquals("target_collection", calls.get(2).getWrites().get(0).getFeature().getId()); // 2nd call is map creation in admin map / maps collection Write mapCreate = calls.get(1).getWrites().get(0); assertEquals(WriteOp.CREATE, mapCreate.getOp()); - assertEquals(Naksha.ADMIN_MAP, mapCreate.getMapId()); - assertEquals(Naksha.CATALOGS_COL, mapCreate.getCollectionId()); + assertEquals(Naksha.ADMIN_CATALOG_ID, mapCreate.getCatalogId()); + assertEquals(Naksha.CATALOGS_COL_ID, mapCreate.getCollectionId()); assertEquals(mapIdFromStorageProps, mapCreate.getId()); } @@ -461,8 +461,8 @@ void shouldApplyMapIdAndCollectionsColForWriteCollections() { verify(storageWriteSession).execute(captor.capture()); Write write = captor.getValue().getWrites().get(0); assertTrue(RequestTypesUtil.isOnlyWriteCollections(captor.getValue())); - assertEquals(Naksha.COLLECTIONS_COL, write.getCollectionId(), "WriteCollections must target naksa~collections collection"); - assertEquals(mapIdFromStorageProps, write.getMapId(), "MapId must be taken from storage props"); + assertEquals(Naksha.COLLECTIONS_COL_ID, write.getCollectionId(), "WriteCollections must target naksa~collections collection"); + assertEquals(mapIdFromStorageProps, write.getCatalogId(), "MapId must be taken from storage props"); assertEquals("apply_col", write.getFeature().getId()); } @@ -540,7 +540,7 @@ private IStorage registeredStorageWithConfig(NakshaStorage config) { } private static Write findSingleCreateCollectionWrite(List writeRequests) { - List collectionWrites = getSingularWritesToCollection(writeRequests, Naksha.COLLECTIONS_COL); + List collectionWrites = getSingularWritesToCollection(writeRequests, Naksha.COLLECTIONS_COL_ID); assertEquals(1, collectionWrites.size(), "Expected single collection write"); return collectionWrites.get(0); } @@ -560,7 +560,7 @@ private static Stream flattenSingularWriteRequest(List writ private static ArgumentMatcher matchesCreateCollectionRequest() { return writeRequest -> { WriteList writes = writeRequest.getWrites(); - return writes.size() == 1 && Naksha.COLLECTIONS_COL.equals(writes.get(0).getCollectionId()); + return writes.size() == 1 && Naksha.COLLECTIONS_COL_ID.equals(writes.get(0).getCollectionId()); }; } @@ -640,7 +640,7 @@ NakshaCollection correctCollection() { case SPACE_PROPERTIES: return JvmBoxingUtil.box(space.getProperties(), SpaceProperties.class).getCollection(); case SPACE_ID: - return new NakshaCollection(space.getId()).withMapId(getMapId()); + return new NakshaCollection(space.getId()).withCatalogId(getMapId()); default: throw new IllegalStateException("Unexpected collection source: " + validCollectionSource); } @@ -677,8 +677,8 @@ private static Request writeRandomFeature() { private static Request readRandomFeature() { ReadFeatures readFeatures = new ReadFeatures(); - readFeatures.setMapId("random_map_" + RandomUtils.nextInt()); - readFeatures.setCollectionIds(new StringList("random_collection_" + RandomUtils.nextInt())); + readFeatures.setCatalogId("random_map_" + RandomUtils.nextInt()); + readFeatures.setCollectionId(new StringList("random_collection_" + RandomUtils.nextInt())); readFeatures.setFeatureIds(new StringList("random_feature_" + RandomUtils.nextInt())); return readFeatures; } @@ -704,7 +704,7 @@ private static SpaceProperties spacePropertiesWithCollection(String collectionId } final NakshaCollection nakshaCollection = new NakshaCollection(); nakshaCollection.setId(collectionId); - nakshaCollection.setMapId(getMapId()); + nakshaCollection.setCatalogId(getMapId()); SpaceProperties spaceProperties = new SpaceProperties(); spaceProperties.setCollection(nakshaCollection); return spaceProperties; @@ -715,7 +715,7 @@ private static DefaultStorageHandlerProperties handlerPropertiesWithCollection(S NakshaCollection collection = collectionId != null ? new NakshaCollection() : null; if (collection != null) { collection.setId(collectionId); - collection.setMapId(getMapId()); + collection.setCatalogId(getMapId()); } properties.setCollection(collection); return properties; @@ -724,7 +724,7 @@ private static DefaultStorageHandlerProperties handlerPropertiesWithCollection(S private static DefaultStorageHandlerProperties handlerProperties(String storageId) { final NakshaCollection nakshaCollection = new NakshaCollection(); nakshaCollection.setId("handler_collection"); - nakshaCollection.setMapId(getMapId()); + nakshaCollection.setCatalogId(getMapId()); DefaultStorageHandlerProperties properties = new DefaultStorageHandlerProperties(); properties.setStorageId(storageId); properties.setCollection(nakshaCollection); @@ -753,8 +753,8 @@ private static ArgumentMatcher matchesCreateMapRequest(String mapI } Write w = writes.get(0); return WriteOp.CREATE.equals(w.getOp()) - && Naksha.ADMIN_MAP.equals(w.getMapId()) - && Naksha.CATALOGS_COL.equals(w.getCollectionId()) + && Naksha.ADMIN_CATALOG_ID.equals(w.getCatalogId()) + && Naksha.CATALOGS_COL_ID.equals(w.getCollectionId()) && mapId.equals(w.getId()); }; } diff --git a/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/internal/IntHandlerForSpacesTest.java b/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/internal/IntHandlerForSpacesTest.java index 335d30fe66..36844e4a49 100644 --- a/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/internal/IntHandlerForSpacesTest.java +++ b/here-naksha-lib-handlers/src/jvmTest/java/com/here/naksha/lib/handlers/internal/IntHandlerForSpacesTest.java @@ -3,7 +3,6 @@ import static com.here.naksha.lib.core.HubInternalIdentifiers.EVENT_HANDLERS; import static com.here.naksha.lib.core.HubInternalIdentifiers.SPACES; import static java.util.Collections.emptyList; -import static naksha.model.NakshaError.ILLEGAL_ARGUMENT; import static naksha.model.NakshaError.NOT_FOUND; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -138,7 +137,7 @@ private static Stream> persistingWritesWithInvalidSpace() { private static Stream> persistingSpaceWithoutValidHandlers() { Space space = space("space_id", "no_desc", "some_title", List.of("handler_1", "handler_2", "handler_3")); NakshaCollection collection = new NakshaCollection("test_collection"); - collection.setMapId("tes_map_id"); + collection.setCatalogId("tes_map_id"); space.getProperties().setCollection(collection); return Stream.of( named("PUT Space without valid handlers", new WriteRequest().add(new Write().upsertFeature(null, SPACES, space))), @@ -188,7 +187,7 @@ private void handlersExist(List eventHandlerIds) { } private ArgumentMatcher anyReadHandlersRequest() { - return argument -> argument.getCollectionIds().size() == 1 && EVENT_HANDLERS.equals(argument.getCollectionIds().get(0)); + return argument -> argument.getCollectionId().size() == 1 && EVENT_HANDLERS.equals(argument.getCollectionId().get(0)); } private static SuccessResponse successfulResponseWithIds(List ids) { diff --git a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/NakshaHub.java b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/NakshaHub.java index 2a9c2a708b..2e82b0caef 100644 --- a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/NakshaHub.java +++ b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/NakshaHub.java @@ -23,7 +23,7 @@ import static com.here.naksha.lib.core.HubInternalIdentifiers.EVENT_HANDLERS; import static com.here.naksha.lib.core.HubInternalIdentifiers.STORAGES; import static com.here.naksha.lib.core.exceptions.UncheckedException.unchecked; -import static naksha.model.Action.CREATED; +import static naksha.model.Action.CREATE; import static naksha.model.NakshaContext.currentContext; import static naksha.model.util.RequestHelper.createFeatureRequest; import static naksha.model.util.RequestHelper.readFeaturesByIdRequest; @@ -66,7 +66,7 @@ import naksha.model.objects.NakshaCollection; import naksha.model.objects.NakshaFeature; import naksha.model.objects.NakshaFeatureList; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.objects.NakshaStorage; import naksha.model.request.ErrorResponse; import naksha.model.request.ReadFeatures; @@ -199,7 +199,7 @@ public NakshaHub( } private NakshaContext setupMapAndContext(String mapId) { - NakshaMap map = new NakshaMap().withId(mapId); + NakshaCatalog map = new NakshaCatalog().withId(mapId); Write createMap = new Write().upsertMap(map, false); NakshaContext initialContext = NakshaContext.currentContext().withAuthor(NakshaHubConfig.defaultAppName()); psqlStorage.runInWriteSession(SessionOptions.from(initialContext), writer -> { @@ -235,7 +235,7 @@ private void createAdminCollections(NakshaContext nakshaContext, String adminMap NakshaFeatureList createdCollections = successResponse.getFeatures(); for (NakshaFeature createdCollection : createdCollections) { if (Objects.equals( - CREATED.getValue(), + CREATE.getValue(), createdCollection.getProperties().getXyz().getAction())) { logger.info("Collection {} successfully created.", createdCollection.getId()); } @@ -387,7 +387,7 @@ private NakshaHubConfig fetchHubConfigFromDb(String configId, IWriteSession admi @Override public @NotNull ExtensionConfig getExtensionConfig() { - final ReadFeatures readRequest = new ReadFeatures().addCollectionId(EVENT_HANDLERS).withMapId(adminMapId); + final ReadFeatures readRequest = new ReadFeatures().withCollectionId(EVENT_HANDLERS).withCatalogId(adminMapId); final PQuery pQueryExists = new PQuery(new Property(EXTN_ID_PROP_PATH), AnyOp.EXISTS); final PQuery pQueryNotNull = new PQuery(new Property(EXTN_ID_PROP_PATH), AnyOp.IS_NOT_NULL); final IPropertyQuery propertyQuery = new PAnd(pQueryExists, pQueryNotNull); diff --git a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorage.java b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorage.java index 9b55355720..e40636b8cc 100644 --- a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorage.java +++ b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorage.java @@ -88,9 +88,8 @@ public int getHardCap() { return psqlStorage.getNumber(); } - @Override public @NotNull DataEncoding getDataEncoding(@Nullable Object feature, @Nullable Object context) { - return psqlStorage.getDataEncoding(feature, context); + throw new UnsupportedOperationException("Not supported by NHAdminStorage"); } @Override diff --git a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorageReader.java b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorageReader.java index 4d75f0b4d5..c1e3ccd0e0 100644 --- a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorageReader.java +++ b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHAdminStorageReader.java @@ -27,7 +27,7 @@ import naksha.model.NakshaVersion; import naksha.model.SessionOptions; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.request.FeatureTuple; import naksha.model.request.Request; import naksha.model.request.Response; @@ -35,8 +35,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import static naksha.model.LibModelKt.FETCH_ALL; - public class NHAdminStorageReader implements IReadSession { /** @@ -113,32 +111,32 @@ public Response executeParallel(@NotNull Request request) { } @Override - public @Nullable NakshaMap getMapById(@NotNull String mapId) { - return session.getMapById(mapId); + public @Nullable NakshaCatalog getCatalogById(@NotNull String catalogId) { + return session.getCatalogById(catalogId); } @Override - public @Nullable NakshaMap getMapByNumber(int mapNumber) { - return session.getMapByNumber(mapNumber); + public @NotNull naksha.model.MemberProcessorMap getProcessors() { + return session.getProcessors(); } @Override - public @Nullable NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { - return session.getCollectionById(map, collectionId); + public @Nullable NakshaCatalog getCatalogByNumber(int catalogNumber) { + return session.getCatalogByNumber(catalogNumber); } @Override - public void loadTuples(@NotNull List featureTuples, int from, int to, int mode) { - session.loadTuples(featureTuples, from, to, mode); + public @Nullable NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { + return session.getCollectionById(map, collectionId); } @Override - public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { - return session.getCollectionByNumber(map, collectionNumber); + public void loadTuples(@NotNull List featureTuples, int from, int to) { + session.loadTuples(featureTuples, from, to); } @Override - public void loadTuples(@NotNull List featureTuples) { - loadTuples(featureTuples, 0, featureTuples.size(), FETCH_ALL); + public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { + return session.getCollectionByNumber(catalog, collectionNumber); } } diff --git a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorage.java b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorage.java index f38a7d963a..07dee798dd 100644 --- a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorage.java +++ b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorage.java @@ -132,7 +132,6 @@ public IReadSession newReadSession(@Nullable SessionOptions options) { throw new UnsupportedOperationException("Unsupported by NHSpaceStorage"); } - @Override public @NotNull DataEncoding getDataEncoding(@Nullable Object feature, @Nullable Object context) { throw new UnsupportedOperationException("Unsupported by NHSpaceStorage"); } diff --git a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorageReader.java b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorageReader.java index 332dae7f2a..cc9c1f7688 100644 --- a/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorageReader.java +++ b/here-naksha-lib-hub/src/jvmMain/java/com/here/naksha/lib/hub/storages/NHSpaceStorageReader.java @@ -46,14 +46,8 @@ import naksha.model.SessionOptions; import naksha.model.StreamInfo; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; -import naksha.model.request.ErrorResponse; -import naksha.model.request.FeatureTuple; -import naksha.model.request.ReadCollections; -import naksha.model.request.ReadFeatures; -import naksha.model.request.Request; -import naksha.model.request.Response; -import naksha.model.request.SuccessResponse; +import naksha.model.objects.NakshaCatalog; +import naksha.model.request.*; import naksha.model.util.ResultHelper; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -117,11 +111,7 @@ public NHSpaceStorageReader( } private @NotNull Response executeReadFeatures(final @NotNull ReadFeatures rf) { - List collectionIds = rf.getCollectionIds(); - if (collectionIds.size() > 1) { - throw new UnsupportedOperationException("Reading from multiple spaces not supported!"); - } - final String spaceId = collectionIds.get(0); + String spaceId = rf.getCollectionId(); logger.info("ReadFeatures Request against spaceId={}", spaceId); addSpaceIdToStreamInfo(spaceId); if (virtualSpaces.containsKey(spaceId)) { @@ -161,13 +151,7 @@ public NHSpaceStorageReader( } private @NotNull Response executeReadFeaturesFromCustomSpaces(final @NotNull ReadFeatures rf) { - List collectionIds = rf.getCollectionIds(); - if (collectionIds.size() > 1) { - return new ErrorResponse(new NakshaError( - NakshaError.UNSUPPORTED_OPERATION, - "ReadFeatures from multiple collections not supported at present!")); - } - final String spaceId = collectionIds.get(0); + final String spaceId = rf.getCollectionId(); final EventPipeline eventPipeline = pipelineFactory.eventPipeline(); final Response response = setupEventPipelineForSpaceId(spaceId, eventPipeline); if (!(response instanceof SuccessResponse)) { @@ -176,11 +160,6 @@ public NHSpaceStorageReader( return eventPipeline.sendEvent(rf); } - @Override - public void loadTuples(@NotNull List featureTuples) { - loadTuples(featureTuples, 0, featureTuples.size(), FETCH_ALL); - } - record SpaceAndHandlerConfigs(Space space, List eventHandlerConfigs) { } @@ -337,27 +316,32 @@ public Response executeParallel(@NotNull Request request) { } @Override - public @Nullable NakshaMap getMapById(@NotNull String mapId) { + public @Nullable NakshaCatalog getCatalogById(@NotNull String catalogId) { + throw NOT_SUPPORTED_ERROR; + } + + @Override + public @NotNull naksha.model.MemberProcessorMap getProcessors() { throw NOT_SUPPORTED_ERROR; } @Override - public @Nullable NakshaMap getMapByNumber(int mapNumber) { + public @Nullable NakshaCatalog getCatalogByNumber(int catalogNumber) { throw NOT_SUPPORTED_ERROR; } @Override - public @Nullable NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { + public @Nullable NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { throw NOT_SUPPORTED_ERROR; } @Override - public void loadTuples(@NotNull List featureTuples, int from, int to, int mode) { + public void loadTuples(@NotNull List featureTuples, int from, int to) { throw NOT_SUPPORTED_ERROR; } @Override - public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { + public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { throw NOT_SUPPORTED_ERROR; } diff --git a/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/NakshaHubWiringTest.java b/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/NakshaHubWiringTest.java index fad91e86f9..46275c46f1 100644 --- a/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/NakshaHubWiringTest.java +++ b/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/NakshaHubWiringTest.java @@ -142,7 +142,7 @@ void testCreateStorageRequestWiring() { @Order(2) void testGetStoragesRequestWiring() throws Exception { // Given: Read Storage request - final ReadFeatures request = new ReadFeatures().addCollectionId(STORAGES); + final ReadFeatures request = new ReadFeatures().withCollectionId(STORAGES); // And: spies and captors in place final EventPipeline spyPipeline = spy(spyPipelineFactory.eventPipeline()); @@ -184,14 +184,14 @@ void testCreateFeatureRequestWiring() throws Exception { final IStorage spyStorageImpl = spy(storageImpl); when(adminStorageReader.execute(argThat(readRequest -> { if (readRequest instanceof ReadFeatures rr) { - return Objects.equals(rr.getCollectionIds().get(0), SPACES); + return Objects.equals(rr.getCollectionId().get(0), SPACES); } return false; }))) .thenReturn(mockResultWithFeature(space)); when(adminStorageReader.execute(argThat(readRequest -> { if (readRequest instanceof ReadFeatures rr) { - return Objects.equals(rr.getCollectionIds().get(0), EVENT_HANDLERS); + return Objects.equals(rr.getCollectionId().get(0), EVENT_HANDLERS); } return false; }))) @@ -235,7 +235,7 @@ void testCreateFeatureRequestWiring() throws Exception { verify(spyWriter, times(3)).execute(reqCaptor.capture()); assertTrue(reqCaptor.getValue() instanceof WriteRequest); final List requests = reqCaptor.getAllValues(); - final String collectionId = ((Map) space.getProperties().get("collection")) + final String collectionId = ((Map) space.getProperties().getPath("collection")) .get("id") .toString(); // TODO: this is ambiguous (see Space::getCollectionId), discuss // Verify: WriteFeature into collection got called diff --git a/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminReaderMock.java b/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminReaderMock.java index 586bb8d440..121b7d273d 100644 --- a/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminReaderMock.java +++ b/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminReaderMock.java @@ -27,7 +27,6 @@ import java.util.stream.Stream; import naksha.geo.ProxyGeoUtil; import naksha.geo.SpGeometry; -import naksha.jbon.JbDictionary; import naksha.model.IReadSession; import naksha.model.IStorage; import naksha.model.NakshaError; @@ -37,7 +36,7 @@ import naksha.model.objects.NakshaCollection; import naksha.model.objects.NakshaFeature; import naksha.model.objects.NakshaFeatureList; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.request.ErrorResponse; import naksha.model.request.FeatureTuple; import naksha.model.request.ReadFeatures; @@ -95,14 +94,14 @@ public NHAdminReaderMock(final @NotNull Map features = getFeatures(rf.getCollectionIds(), rf.getFeatureIds(), rf.getQuery()); + List features = getFeatures(rf.getCollectionId(), rf.getFeatureIds(), rf.getQuery()); SuccessResponse response = new SuccessResponse(); response.setFeatures(NakshaFeatureList.fromList(features)); return response; } private List getFeatures(List collectionIds, List featureIds, RequestQuery query) { - if (query.getProperties() != null || query.getMetadata() != null || !query.getRefTiles().isEmpty()) { + if (query.getProperties() != null || query.getMembers() != null || !query.getRefTiles().isEmpty()) { throw new NakshaException(new NakshaError(NakshaError.ILLEGAL_ARGUMENT, "Mock supports only tags and spatial query")); } final List allFeaturesFromCollections = getAllFeaturesFromCollections(collectionIds); @@ -280,22 +279,22 @@ public Response executeParallel(@NotNull Request request) { } @Override - public @Nullable NakshaMap getMapById(@NotNull String mapId) { + public @Nullable NakshaCatalog getMapById(@NotNull String mapId) { throw new NakshaException(new NakshaError(NakshaError.UNSUPPORTED_OPERATION, "Not supported by mock yet")); } @Override - public @Nullable NakshaMap getMapByNumber(int mapNumber) { + public @Nullable NakshaCatalog getCatalogByNumber(int catalogNumber) { throw new NakshaException(new NakshaError(NakshaError.UNSUPPORTED_OPERATION, "Not supported by mock yet")); } @Override - public @Nullable NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { + public @Nullable NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { throw new NakshaException(new NakshaError(NakshaError.UNSUPPORTED_OPERATION, "Not supported by mock yet")); } @Override - public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { + public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { throw new NakshaException(new NakshaError(NakshaError.UNSUPPORTED_OPERATION, "Not supported by mock yet")); } diff --git a/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminWriterMock.java b/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminWriterMock.java index 21e4683a05..2aae972d9b 100644 --- a/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminWriterMock.java +++ b/here-naksha-lib-hub/src/jvmTest/java/com/here/naksha/lib/hub/mock/NHAdminWriterMock.java @@ -51,7 +51,7 @@ public NHAdminWriterMock(final @NotNull Map write .hasOp(WriteOp.DELETE) - .hasCollectionId(Naksha.COLLECTIONS_COL) + .hasCollectionId(Naksha.COLLECTIONS_COL_ID) .hasId(CUSTOM_SPACE) ); @@ -132,7 +129,7 @@ void shouldNotTriggerSpaceEntryDeletionWhenPurgingFailed() { assertThatWriteRequest(requestsPassedToPipeline.get(0)) .hasSingleWriteThat(write -> write .hasOp(WriteOp.DELETE) - .hasCollectionId(Naksha.COLLECTIONS_COL) + .hasCollectionId(Naksha.COLLECTIONS_COL_ID) .hasId(CUSTOM_SPACE) ); @@ -162,7 +159,7 @@ void shouldFailWhenSpaceEntryDeletionFailed() { assertThatWriteRequest(requestsPassedToPipeline.get(0)) .hasSingleWriteThat(write -> write .hasOp(WriteOp.DELETE) - .hasCollectionId(Naksha.COLLECTIONS_COL) + .hasCollectionId(Naksha.COLLECTIONS_COL_ID) .hasId(CUSTOM_SPACE) ); @@ -203,7 +200,7 @@ private EventPipeline eventPipelineFailingOn(ArgumentMatcher faili private ArgumentMatcher writeCollectionRequest() { return writeRequest -> { List writes = writeRequest.getWrites(); - return writes.size() == 1 && writes.get(0).getCollectionId().equals(Naksha.COLLECTIONS_COL); + return writes.size() == 1 && writes.get(0).getCollectionId().equals(Naksha.COLLECTIONS_COL_ID); }; } diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/BookType.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/BookType.kt new file mode 100644 index 0000000000..d1bd69639b --- /dev/null +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/BookType.kt @@ -0,0 +1,38 @@ +@file:OptIn(ExperimentalJsExport::class) + +package naksha.jbon + +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport +import kotlin.jvm.JvmField + +/** + * The type of the book as defined in JBON2 specification. + * @since 3.0 + */ +@JsExport +enum class BookType( + /** + * The type-number as being used in JBON2 encoding. + * @since 3.0 + */ + @JvmField val typeNumber: Int +) { + /** + * Local book, embedded into the `Tuple` binary. + * @since 3.0 + */ + LOCAL_BOOK(0), + + /** + * Members book, provided by the storage, members extrapolated into dedicated storage places. Can be embedded into the `Tuple`. + * @since 3.0 + */ + MEMBER_BOOK(1), + + /** + * Global book, persisted in the storage, shared between many `Tuple` to reduce size, compression utility. + * @since 3.0 + */ + GLOBAL_BOOK(2), +} \ No newline at end of file diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/HeapBook.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/HeapBook.kt index 07967819c6..43caf0fd99 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/HeapBook.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/HeapBook.kt @@ -2,14 +2,58 @@ package naksha.jbon +import naksha.base.Int64 import kotlin.js.JsExport +import kotlin.js.JsStatic +import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic /** * A mutable [IBook] implementation on the Java _HEAP_. * @since 3.0.0 */ @JsExport -class HeapBook : IBook { +class HeapBook( + override var bookType: BookType +) : IBook { + companion object HeapBook_C { + /** + * Creates a copy of the given book. + * @param other The [IBook] to make a copy of. + * @param databaseNumber The database-number of the copy, if different. + * @param featureNumber The feature-number of the copy, if different. + * @return a new [HeapBook] with the same entries. + * @since 3.0.0 + */ + @JvmStatic + @JsStatic + @JvmOverloads + fun copyOf(other: IBook, databaseNumber: Int64? = other.databaseNumber, featureNumber: Int64? = other.featureNumber): HeapBook { + val c = HeapBook(other.bookType) + c.id = other.id + c.databaseNumber = other.databaseNumber + c.featureNumber = other.featureNumber + if (other is HeapBook) { + c._names.addAll(other._names) + c._values.addAll(other._values) + for ((name,index) in other._nameIndex) c._nameIndex[name] = index + } else { + val namesLen = other.namesLength() + for (i in 0..() private val _values = mutableListOf() private val _nameIndex = mutableMapOf() @@ -21,9 +65,9 @@ class HeapBook : IBook { override fun get(index: Int): Any? = _values.getOrNull(index) - override fun indexOf(string: String): Int = _nameIndex[string] ?: -1 + override fun indexOfString(string: String): Int = _nameIndex[string] ?: -1 - override fun stringAt(index: Int): String? { + override fun getStringAt(index: Int): String? { val name = _names.getOrNull(index) if (name != null) return name return _values.getOrNull(index) as? String @@ -31,26 +75,32 @@ class HeapBook : IBook { override fun hasNames(): Boolean = true - override fun getIndexOf(name: String): Int = _nameIndex[name] ?: -1 + override fun indexOfName(name: String): Int = _nameIndex[name] ?: -1 override fun getNameAt(index: Int): String? = _names.getOrNull(index) override fun namesLength(): Int = _names.size - override fun getByName(name: String): Any? { + override fun get(name: String): Any? { val i = _nameIndex[name] ?: return null return _values.getOrNull(i) } - override fun find(hash: Int): List = emptyList() + override fun getAllWithHash(hash: Int): List = emptyList() /** * Creates a shallow copy of this dictionary. + * @param bookType The [BookType] of the copy, if different. + * @param databaseNumber The database-number of the copy, if different. + * @param featureNumber The feature-number of the copy, if different. * @return a new [HeapBook] with the same entries. * @since 3.0.0 */ - fun copy(): HeapBook { - val c = HeapBook() + @JvmOverloads + fun copy(bookType: BookType = this.bookType, databaseNumber: Int64? = this.databaseNumber, featureNumber: Int64? = this.featureNumber): HeapBook { + val c = HeapBook(bookType) + c.databaseNumber = this.databaseNumber + c.featureNumber = this.featureNumber for (i in _names.indices) { c.put(_names[i], _values[i]) } @@ -62,17 +112,19 @@ class HeapBook : IBook { * If the name already exists, the value is updated in-place. * @param name the member name. * @param value the value. + * @return The index at which the value was placed. * @since 3.0.0 */ - fun put(name: String, value: Any?) { + fun put(name: String, value: Any?): Int { val index = _nameIndex[name] if (index != null) { _values[index] = value - } else { - val idx = _names.size - _names.add(name) - _nameIndex[name] = idx - _values.add(value) + return index } + val idx = _names.size + _names.add(name) + _nameIndex[name] = idx + _values.add(value) + return idx } } diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IBook.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IBook.kt index 8f3a786577..7cdc414ad5 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IBook.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IBook.kt @@ -2,7 +2,9 @@ package naksha.jbon +import naksha.base.Int64 import kotlin.js.JsExport +import kotlin.js.JsName /** * An interface to be implemented by all books. Books can contain a combination of the following types, and only of these: @@ -20,7 +22,7 @@ import kotlin.js.JsExport @JsExport interface IBook { /** - * The identifier of the dictionary; if any. + * The optional custom identifier of the book; if any. * @since 3.0.0 */ val id: String? @@ -31,13 +33,44 @@ interface IBook { */ val length: Int + /** + * The book-type. + * @since 3.0 + */ + val bookType: BookType + + /** + * The database-number of the book, if this is a global book stored in a database. + * @since 3.0 + */ + val databaseNumber: Int64? + + /** + * The feature-number of the book, if this is a global book stored in a database. + * @since 3.0 + */ + val featureNumber: Int64? + /** * Returns the element at the given index. If no such index exists, returns _null_. * @param index the index to query. * @return the value being one of: `null`, `Boolean`, `Int`, `Int64`, `Double`, `String`, `Map`, or `List`. - * @since 3.0.0 + * @since 3.0 */ - fun get(index: Int): Any? + @JsName("getByIndex") + operator fun get(index: Int): Any? + + /** + * Returns the value associated with the given name by looking up the index via [indexOfName] and then reading the value via [get]. Returns _null_ when the name is not found or the index maps to a _null_ slot. + * @param name the member name to look up. + * @return the value, or _null_ if the name is not present. + * @since 3.0 + */ + @JsName("getByName") + operator fun get(name: String): Any? { + val i = indexOfName(name) + return if (i < 0) null else get(i) + } /** * Returns the index of the given string or -1, if the string is not part of the dictionary. @@ -46,7 +79,7 @@ interface IBook { * @return the index of the given string or -1, if the string is not part of the dictionary. * @since 3.0.0 */ - fun indexOf(string: String): Int + fun indexOfString(string: String): Int /** * Returns the string at the given index. If no such index exists, returns _null_. @@ -54,7 +87,7 @@ interface IBook { * @return the string or _null_. * @since 3.0.0 */ - fun stringAt(index: Int): String? + fun getStringAt(index: Int): String? /** * Returns `true` if this dictionary contains a `memberNames` section — a parallel array @@ -70,7 +103,7 @@ interface IBook { * @return the index, or `-1`. * @since 3.0.0 */ - fun getIndexOf(name: String): Int = -1 + fun indexOfName(name: String): Int = -1 /** * Returns the name at the given index from the `memberNames` section, or `null` if no @@ -88,24 +121,11 @@ interface IBook { */ fun namesLength(): Int = 0 - /** - * Returns the value associated with the given name by looking up the index via [getIndexOf] - * and then reading the value via [get]. Returns _null_ when the name is not found or the - * index maps to a _null_ slot. - * @param name the member name to look up. - * @return the value, or _null_ if the name is not present. - * @since 3.0.0 - */ - fun getByName(name: String): Any? { - val i = getIndexOf(name) - return if (i < 0) null else get(i) - } - /** * Find all entries in the dictionary that have the given hash. * @param hash the hash to find. * @return a list of all entries that match the given hash. * @since 3.0.0 */ - fun find(hash: Int): List + fun getAllWithHash(hash: Int): List } \ No newline at end of file diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IDictReader.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IDictReader.kt index 56bde9a0dd..292456b72f 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IDictReader.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IDictReader.kt @@ -9,6 +9,7 @@ import kotlin.js.JsExport * @since 3.0 */ @JsExport +@Deprecated("To be removed") interface IDictReader { /** * Retrieve the dictionary with the given identifier. @@ -16,7 +17,7 @@ interface IDictReader { * @return The global dictionary with the given identifier; _null_ when no such dictionary exists. * @since 3.0 */ - fun getDictionary(id: String): JbDictionary? + fun getDictionary(id: String): JbDictionary? = null /** * The best dictionary to encode the given feature. diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IMemberEncoder.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IMemberEncoder.kt index 08fc1a90dc..9d8d6285af 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IMemberEncoder.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/IMemberEncoder.kt @@ -8,8 +8,7 @@ package naksha.jbon */ fun interface IMemberEncoder { /** - * @param path The current path segments (root is empty). The current key/index is at - * `path[pathEnd - 1]` when `pathEnd > 0`. + * @param path The current path segments (root is empty). The current key/index is at `path[pathEnd - 1]` when `pathEnd > 0`. * @param pathEnd The amount of valid path segments in [path]. * @param value The value that is about to be encoded. * @return members-book index (>= 0) or -1 to continue normal encoding. diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDecoder2.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDecoder2.kt index 0aacf8e790..08fbf3aa09 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDecoder2.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDecoder2.kt @@ -11,8 +11,9 @@ import kotlin.jvm.JvmStatic * * This reader is intentionally focused on the subset that [JbEncoder2] produces: the `@JB\x02` * file header, primitives, strings (with string-references resolved against a book), and the - * structures [Array], [Object], [Map], [Dictionary], [Book] and [Tuple]. It is enough to keep the - * read path and round-trip tests working; the full JBON2 type set can be added incrementally. + * structures [Array], [Object], [Map], [TagList], [TagMap], [Dictionary], [Book] and [Tuple]. + * It is enough to keep the read path and round-trip tests working; the full JBON2 type set + * can be added incrementally. * * The decoder maps a single top-level unit. To decode a stored tuple, call [mapBytes] which will * skip the file header (if present), descend into the [Tuple], and expose the feature [Object]. @@ -347,7 +348,7 @@ open class JbDecoder2(var globalDict: IBook? = null, var membersDict: IBook? = n val contentStart = at + hs val contentEnd = contentStart + contentSize return when (type) { - JB2_STRUCT_ARRAY -> { + JB2_STRUCT_ARRAY, JB2_STRUCT_TAG_LIST -> { val list = AnyList() var p = contentStart while (p < contentEnd) { @@ -356,7 +357,7 @@ open class JbDecoder2(var globalDict: IBook? = null, var membersDict: IBook? = n } list } - JB2_STRUCT_OBJECT, JB2_STRUCT_MAP -> { + JB2_STRUCT_OBJECT, JB2_STRUCT_MAP, JB2_STRUCT_TAG_MAP -> { val obj = AnyObject() var p = contentStart while (p < contentEnd) { diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDictionary.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDictionary.kt index 9e1c251d19..07fa354c25 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDictionary.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbDictionary.kt @@ -1,10 +1,16 @@ +@file:OptIn(ExperimentalJsExport::class) + package naksha.jbon +import naksha.base.Int64 +import kotlin.js.ExperimentalJsExport import kotlin.js.JsExport -// TODO: Implement IBook +@JsExport +class JbDictionary(override val bookType: BookType = BookType.LOCAL_BOOK,) : JbStructDecoder(), IBook { + override var databaseNumber: Int64? = null + override var featureNumber: Int64? = null -class JbDictionary : JbStructDecoder(), IBook { /** * Cached ID of the dictionary, if any. */ @@ -110,11 +116,11 @@ class JbDictionary : JbStructDecoder(), IBook { return content[index] } - override fun stringAt(index: Int): String? { + override fun getStringAt(index: Int): String? { TODO("Not yet implemented") } - override fun find(hash: Int): List { + override fun getAllWithHash(hash: Int): List { TODO("Not yet implemented") } @@ -123,7 +129,7 @@ class JbDictionary : JbStructDecoder(), IBook { * as a side effect invoke [loadAll]. * @return The index of the given string or -1. */ - override fun indexOf(string: String): Int { + override fun indexOfString(string: String): Int { loadAll() val content = this.content val length = content.size diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder.kt index d68b5a372f..6a8276d5d1 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder.kt @@ -533,7 +533,7 @@ open class JbEncoder(var global: IBook? = null) : Binary() { val isGlobal: Boolean var index = -1 if (global != null) { - index = global.indexOf(subString) + index = global.indexOfString(subString) if (index < 0) { // Let's try for URNs, which are for example "urn:here:mom:Topology:123456". // In that case, "urn:here:mom:Topology:" is always the same. @@ -547,7 +547,7 @@ open class JbEncoder(var global: IBook? = null) : Binary() { sb.clear() JbDecoder.readSubstring(this, wordStart, reversePos, sb) val prefix = sb.toString() - index = global.indexOf(prefix) + index = global.indexOfString(prefix) if (index >= 0) { // Found the prefix in the global dict, now we encode the prefix as reference. pos = encodeStringRef(wordStart, index, true, ADD_COLON) @@ -775,7 +775,7 @@ open class JbEncoder(var global: IBook? = null) : Binary() { val global = this.global var index: Int if (global != null) { - index = global.indexOf(key) + index = global.indexOfString(key) if (index >= 0) { encodeRef(index, true) return start diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder2.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder2.kt index 05ee66bb6c..c7f21d1c0c 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder2.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/JbEncoder2.kt @@ -24,6 +24,11 @@ import kotlin.math.floor * when a [global] book is provided; the JBON2 string-reference format (`11_aaa_bbs`, 7 append * characters) is used. * + * Special structures: + * - [TagList][encodeTagList] (`JB2_STRUCT_TAG_LIST`): a list of unique primitive values where order + * is significant, but must not have duplicates, null or undefined. + * - [TagMap][encodeTagMap] (`JB2_STRUCT_TAG_MAP`): a string-keyed map with primitive values. + * * @property global The global book/dictionary to use when encoding; if any. */ @Suppress("DuplicatedCode", "MemberVisibilityCanBePrivate", "OPT_IN_USAGE") @@ -512,7 +517,7 @@ open class JbEncoder2(var global: IBook? = null) : Binary() { var index = -1 var book = JB2_REF_BOOK_LOCAL if (global != null) { - index = global.indexOf(subString) + index = global.indexOfString(subString) if (index < 0) { // Try URN-style prefixes ending in a colon. var reversePos = pos - 1 @@ -523,7 +528,7 @@ open class JbEncoder2(var global: IBook? = null) : Binary() { sb.clear() JbDecoder2.readSubstring(this, wordStart, reversePos, sb) val prefix = sb.toString() - val pidx = global.indexOf(prefix) + val pidx = global.indexOfString(prefix) if (pidx >= 0) { pos = encodeStringRef(wordStart, pidx, JB2_REF_BOOK_GLOBAL, JB2_ADD_COLON) i = reversePos + 1 @@ -672,7 +677,7 @@ open class JbEncoder2(var global: IBook? = null) : Binary() { val start = end val global = this.global if (global != null) { - val index = global.indexOf(key) + val index = global.indexOfString(key) if (index >= 0) { encodeRef(index, JB2_REF_BOOK_GLOBAL) return start @@ -748,6 +753,47 @@ open class JbEncoder2(var global: IBook? = null) : Binary() { return start } + /** + * Write a TagList recursively. A TagList is a list of unique primitive values where order + * is significant, but must not have duplicates, null or undefined. + */ + fun encodeTagList(list: ListProxy<*>): Int { + val start = startStruct() + var i = 0 + while (i < list.size) { + pushPath(i) + try { + encodeValue(list[i]) + } finally { + popPath() + } + i++ + } + endStruct(JB2_STRUCT_TAG_LIST, start) + return start + } + + /** + * Write a TagMap recursively. A TagMap is a string-keyed map with primitive values. + * Uses dictionary compression for keys when a global book is provided. + */ + fun encodeTagMap(map: MapProxy): Int { + val start = startStruct() + for (entry in map) { + val key = entry.key + val value = entry.value + writeKey(key) + pushPath(key) + try { + encodeValue(value) + } finally { + popPath() + } + } + endStruct(JB2_STRUCT_TAG_MAP, start) + return start + } + /** * Write a [JB2_STRUCT_TWKB] structure from raw TWKB bytes. * @@ -825,8 +871,8 @@ open class JbEncoder2(var global: IBook? = null) : Binary() { is Double -> if (Platform.canBeFloat32(value)) encodeFloat32(value.toFloat()) else encodeFloat64(value) is SpGeometry -> encodeGeometry(value) is ByteArray -> if (value.isNotEmpty()) encodeByteArray(value) else encodeNull() - is MapProxy<*, *> -> encodeObject(value as MapProxy) - is ListProxy<*> -> encodeList(value) + is MapProxy<*, *> -> if (value::class.simpleName == "TagMap") encodeTagMap(value as MapProxy) else encodeObject(value as MapProxy) + is ListProxy<*> -> if (value::class.simpleName == "TagList") encodeTagList(value) else encodeList(value) is Array<*> -> encodeArray(value as Array) null -> encodeNull() else -> throw IllegalArgumentException("Could not encode value for type: ${value::class}") diff --git a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/LibJbon2.kt b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/LibJbon2.kt index 7188e7ab99..378798329c 100644 --- a/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/LibJbon2.kt +++ b/here-naksha-lib-jbon/src/commonMain/kotlin/naksha/jbon/LibJbon2.kt @@ -179,9 +179,14 @@ internal const val JB2_STRUCT_SIZE32 = 0b0011_0000 internal const val JB2_STRUCT_TYPE_MASK = 0b0000_1111 internal const val JB2_STRUCT_ARRAY = 0 // 0000 internal const val JB2_STRUCT_MAP = 1 // 0001 -internal const val JB2_STRUCT_SET = 2 // 0010 +/** + * TagList: a list of unique primitive values (booleans, numbers, strings). Value order is + * significant, but must not have duplicates, null or undefined. + */ +internal const val JB2_STRUCT_TAG_LIST = 2 // 0010 internal const val JB2_STRUCT_OBJECT = 3 // 0011 -internal const val JB2_STRUCT_TAGS = 4 // 0100 +/** TagMap: a string-keyed map with primitive values. */ +internal const val JB2_STRUCT_TAG_MAP = 4 // 0100 internal const val JB2_STRUCT_DICTIONARY = 5 // 0101 internal const val JB2_STRUCT_BOOK = 6 // 0110 internal const val JB2_STRUCT_TUPLE_NUMBER_ARRAY = 7 // 0111 diff --git a/here-naksha-lib-jbon/src/commonTest/kotlin/naksha/jbon/JbCoreTest.kt b/here-naksha-lib-jbon/src/commonTest/kotlin/naksha/jbon/JbCoreTest.kt index fcfd793380..cccd3d8933 100644 --- a/here-naksha-lib-jbon/src/commonTest/kotlin/naksha/jbon/JbCoreTest.kt +++ b/here-naksha-lib-jbon/src/commonTest/kotlin/naksha/jbon/JbCoreTest.kt @@ -493,10 +493,10 @@ class JbCoreTest { assertEquals(dictId, dict.id) assertEquals("foo", dict.get(0)) assertEquals("bar", dict.get(1)) - assertEquals(0, dict.indexOf("foo")) - assertEquals(1, dict.indexOf("bar")) - assertEquals(-1, dict.indexOf(dictId)) - assertEquals(-1, dict.indexOf("notFound")) + assertEquals(0, dict.indexOfString("foo")) + assertEquals(1, dict.indexOfString("bar")) + assertEquals(-1, dict.indexOfString(dictId)) + assertEquals(-1, dict.indexOfString("notFound")) } @Test diff --git a/here-naksha-lib-jbon/src/jvmTest/kotlin/naksha/jbon/Jbon2MembersTest.kt b/here-naksha-lib-jbon/src/jvmTest/kotlin/naksha/jbon/Jbon2MembersTest.kt index ca08aaf79f..3a16ba4dee 100644 --- a/here-naksha-lib-jbon/src/jvmTest/kotlin/naksha/jbon/Jbon2MembersTest.kt +++ b/here-naksha-lib-jbon/src/jvmTest/kotlin/naksha/jbon/Jbon2MembersTest.kt @@ -32,9 +32,9 @@ class Jbon2MembersTest { override val id: String? = null override val length: Int get() = entries.size override fun get(index: Int): Any? = entries.getOrNull(index) - override fun indexOf(string: String): Int = entries.indexOfFirst { it == string } - override fun stringAt(index: Int): String? = entries.getOrNull(index)?.toString() - override fun find(hash: Int): List = emptyList() + override fun indexOfString(string: String): Int = entries.indexOfFirst { it == string } + override fun getStringAt(index: Int): String? = entries.getOrNull(index)?.toString() + override fun getAllWithHash(hash: Int): List = emptyList() } // ----------------------------------------------------------------------- diff --git a/here-naksha-lib-mm-util/src/jvmMain/java/com/here/naksha/mom10/Mom10Transformation.java b/here-naksha-lib-mm-util/src/jvmMain/java/com/here/naksha/mom10/Mom10Transformation.java index 8590db2c77..4fb470423b 100644 --- a/here-naksha-lib-mm-util/src/jvmMain/java/com/here/naksha/mom10/Mom10Transformation.java +++ b/here-naksha-lib-mm-util/src/jvmMain/java/com/here/naksha/mom10/Mom10Transformation.java @@ -41,7 +41,7 @@ public static void populatePreMom10Namespaces(@Nullable NakshaFeature feature) { } NakshaProperties properties = feature.getProperties(); - Map meta = (Map) properties.get(META); + Map meta = (Map) properties.getPath(META); if (meta != null && !meta.isEmpty()) { MomDeltaNs deltaNs = deltaNs(meta); properties.setDelta(deltaNs); diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/AbstractStorage.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/AbstractStorage.kt index caa62ebf1b..d5e53d1ca6 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/AbstractStorage.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/AbstractStorage.kt @@ -79,7 +79,7 @@ abstract class AbstractStorage : IStorage { if (configRef.get() == null || create==true || upgrade==true) { val _config = storage.proxy(configKlass) this._id = storage.id - this._number = storage.number + this._number = Naksha.featureNumber(storage.id) this.hardCap = storage.hardCap initStorage(_config, create, upgrade) this.configRef.set(_config) diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Action.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Action.kt index 03cb79ed39..44f1ead68c 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Action.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Action.kt @@ -1,5 +1,6 @@ package naksha.model +import naksha.base.Int64 import naksha.base.JsEnum import kotlin.js.JsExport import kotlin.js.JsStatic @@ -8,13 +9,13 @@ import kotlin.jvm.JvmStatic import kotlin.reflect.KClass /** - * An enumeration about the action that actually was performed for a feature in a storage, being [CREATED], [UPDATED], or [DELETED]. + * An enumeration about the action that actually was performed for a feature in a storage, being [CREATE], [UPDATE], or [DELETE]. * - * The numeric [intValue] corresponds to the lower two bits of a [Version.txn]: - * - `0` ([CREATED]) — the feature was created in this version. - * - `1` ([UPDATED]) — the feature was updated in this version. - * - `2` ([DELETED]) — the feature was deleted in this version. - * - `3` ([VERSION]) — both action bits are set; used as a sentinel to indicate that the [Version.txn] value itself + * The numeric [intValue] corresponds to the lower two bits of a [Version.number]: + * - `0` ([CREATE]) — the feature was created in this version. + * - `1` ([UPDATE]) — the feature was updated in this version. + * - `2` ([DELETE]) — the feature was deleted in this version. + * - `3` ([VERSION]) — both action bits are set; used as a sentinel to indicate that the [Version.number] value itself * is being used as a version reference rather than encoding a state-change action. * * @since 1.0.0 @@ -29,17 +30,17 @@ class Action : JsEnum() { @Suppress("MemberVisibilityCanBePrivate") companion object Action_C { - internal const val CREATED_VALUE = 0 - internal const val CREATED_STRING = "CREATE" - internal const val CREATED_SHORT = "c" + internal const val CREATE_VALUE = 0 + internal const val CREATE_STRING = "CREATE" + internal const val CREATE_SHORT = "c" - internal const val UPDATED_VALUE = 1 - internal const val UPDATED_STRING = "UPDATE" - internal const val UPDATED_SHORT = "u" + internal const val UPDATE_VALUE = 1 + internal const val UPDATE_STRING = "UPDATE" + internal const val UPDATE_SHORT = "u" - internal const val DELETED_VALUE = 2 - internal const val DELETED_STRING = "DELETE" - internal const val DELETED_SHORT = "d" + internal const val DELETE_VALUE = 2 + internal const val DELETE_STRING = "DELETE" + internal const val DELETE_SHORT = "d" internal const val VERSION_VALUE = 3 internal const val VERSION_STRING = "VERSION" @@ -51,9 +52,9 @@ class Action : JsEnum() { */ @JsStatic @JvmField - val CREATED = defIgnoreCase(Action::class, CREATED_STRING) { self -> - self.intValue = CREATED_VALUE - self.shortId = CREATED_SHORT + val CREATE = defIgnoreCase(Action::class, CREATE_STRING) { self -> + self.intValue = CREATE_VALUE + self.shortId = CREATE_SHORT } /** @@ -62,9 +63,9 @@ class Action : JsEnum() { */ @JsStatic @JvmField - val UPDATED = defIgnoreCase(Action::class, UPDATED_STRING) { self -> - self.intValue = UPDATED_VALUE - self.shortId = UPDATED_SHORT + val UPDATE = defIgnoreCase(Action::class, UPDATE_STRING) { self -> + self.intValue = UPDATE_VALUE + self.shortId = UPDATE_SHORT } /** @@ -73,13 +74,13 @@ class Action : JsEnum() { */ @JsStatic @JvmField - val DELETED = defIgnoreCase(Action::class, DELETED_STRING) { self -> - self.intValue = DELETED_VALUE - self.shortId = DELETED_SHORT + val DELETE = defIgnoreCase(Action::class, DELETE_STRING) { self -> + self.intValue = DELETE_VALUE + self.shortId = DELETE_SHORT } /** - * Both action bits are set (`3`). Used as a sentinel to signal that the [Version.txn] value + * Both action bits are set (`3`). Used as a sentinel to signal that the [Version.number] value * is a version reference rather than a state-change action. Also returned by [fromValue] for * any unrecognised integer value. * @since 1.0.0 @@ -93,16 +94,16 @@ class Action : JsEnum() { // Full-name and short-name lookup map. private val FROM_STRING = mapOf( - Pair(CREATED_STRING, CREATED), Pair(CREATED_SHORT, CREATED), - Pair(UPDATED_STRING, UPDATED), Pair(UPDATED_SHORT, UPDATED), - Pair(DELETED_STRING, DELETED), Pair(DELETED_SHORT, DELETED), + Pair(CREATE_STRING, CREATE), Pair(CREATE_SHORT, CREATE), + Pair(UPDATE_STRING, UPDATE), Pair(UPDATE_SHORT, UPDATE), + Pair(DELETE_STRING, DELETE), Pair(DELETE_SHORT, DELETE), Pair(VERSION_STRING, VERSION), Pair(VERSION_SHORT, VERSION), ) private val FROM_VALUE = mapOf( - Pair(CREATED_VALUE, CREATED), - Pair(UPDATED_VALUE, UPDATED), - Pair(DELETED_VALUE, DELETED), + Pair(CREATE_VALUE, CREATE), + Pair(UPDATE_VALUE, UPDATE), + Pair(DELETE_VALUE, DELETE), Pair(VERSION_VALUE, VERSION), ) @@ -119,6 +120,13 @@ class Action : JsEnum() { @JsStatic @JvmStatic fun fromValue(value: Int): Action = FROM_VALUE[value] ?: VERSION + + /** + * Helper to obtain an [Action] from its 64-bit version value. Returns [VERSION] for unrecognised values. + */ + @JsStatic + @JvmStatic + fun fromVersion(version: Int64): Action = FROM_VALUE[version.toInt() and 3] ?: VERSION } /** diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/BinaryUtil.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/BinaryUtil.kt index 9692937c15..ca15353e6b 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/BinaryUtil.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/BinaryUtil.kt @@ -211,7 +211,7 @@ class BinaryUtil private constructor() { pos += 8 } else fn = featureNumber val txn = dataview_get_int64(view, pos) - return TupleNumber(sn, mn, cn, fn, Version(txn)) + return TupleNumber(sn, mn, cn, fn, txn) } } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FeatureMemberValues.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FeatureMemberValues.kt new file mode 100644 index 0000000000..cb5e3f99e8 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FeatureMemberValues.kt @@ -0,0 +1,200 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model + +import naksha.base.AnyObject +import naksha.base.Int64 +import naksha.base.Platform.PlatformCompanion.logger +import naksha.base.Platform.PlatformCompanion.toJSON +import naksha.geo.GeoUtil.GeoUtil_C.toTWKB +import naksha.geo.SpGeometry +import naksha.model.objects.MemberType +import naksha.model.objects.NakshaFeature + +/** + * Helpers to walk a [NakshaFeature] by JSON path and coerce raw values to the expected [MemberType]. + * + * - [walkFeature]: descend a [NakshaFeature] using the member's path; returns _null_ if the path is missing. + * - [coerce]: coerce a raw value to the type of the member; returns _null_ and logs a warning on mismatch. + * + * @since 3.0 + */ +object FeatureMemberValues { + + /** + * Descend a [NakshaFeature] using the given [path] segments; returns _null_ if any segment is missing. + * + * @param feature the feature to walk. + * @param path the list of path segments (e.g. `["properties", "@ns:com:here:xyz", "updatedAt"]`). + * @return the value at the path, or _null_ if the path does not exist. + */ + fun walkFeature(feature: NakshaFeature, path: List): Any? { + var current: Any? = feature + for (segment in path) { + if (current == null) return null + current = when (current) { + is AnyObject -> current.getRaw(segment) + else -> return null + } + } + return current + } + + /** + * Coerce a raw value to the type expected by the member. + * + * For [MemberType.SPATIAL], the value is expected to be a [SpGeometry] (or [naksha.geo.SpPoint]) which will be + * encoded as TWKB bytes. For other types, the value is expected to already be the raw JSON value. + * + * @param value the raw value extracted from the feature. + * @param type the expected [MemberType]. + * @param featureId the feature identifier (for logging). + * @param memberName the member name (for logging). + * @return the coerced value, or _null_ if coercion failed. + */ + fun coerce(value: Any?, type: MemberType, featureId: String, memberName: String): Any? { + if (value == null) return null + return when (type) { + MemberType.BOOLEAN -> coerceBoolean(value, featureId, memberName) + MemberType.INT8 -> coerceInt8(value, featureId, memberName) + MemberType.INT16 -> coerceInt16(value, featureId, memberName) + MemberType.INT32 -> coerceInt32(value, featureId, memberName) + MemberType.INT64 -> coerceInt64(value, featureId, memberName) + MemberType.FLOAT32 -> coerceFloat32(value, featureId, memberName) + MemberType.FLOAT64 -> coerceFloat64(value, featureId, memberName) + MemberType.STRING -> coerceString(value, featureId, memberName) + MemberType.BYTE_ARRAY -> coerceByteArray(value, featureId, memberName) + MemberType.SPATIAL -> coerceSpatial(value, featureId, memberName) + MemberType.TAG_MAP -> coerceTags(value, featureId, memberName) + MemberType.TAG_MAP_FROM_ARRAY -> coerceTagsFromArray(value, featureId, memberName) + else -> { + warnMismatch(featureId, memberName, type.toString(), value) + null + } + } + } + + // ------------------------------------------------------------------------- + // Individual coercion implementations + // ------------------------------------------------------------------------- + + private fun coerceBoolean(value: Any, featureId: String, memberName: String): Boolean? = when (value) { + is Boolean -> value + else -> { warnMismatch(featureId, memberName, "boolean", value); null } + } + + private fun coerceInt8(value: Any, featureId: String, memberName: String): Short? { + val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int8", value) } + if (asLong !in Byte.MIN_VALUE.toLong()..Byte.MAX_VALUE.toLong()) { + warnMismatch(featureId, memberName, "int8 (out of range)", value) + return null + } + return asLong.toShort() + } + + private fun coerceInt16(value: Any, featureId: String, memberName: String): Short? { + val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int16", value) } + if (asLong !in Short.MIN_VALUE.toLong()..Short.MAX_VALUE.toLong()) { + warnMismatch(featureId, memberName, "int16 (out of range)", value) + return null + } + return asLong.toShort() + } + + private fun coerceInt32(value: Any, featureId: String, memberName: String): Int? { + val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int32", value) } + if (asLong !in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + warnMismatch(featureId, memberName, "int32 (out of range)", value) + return null + } + return asLong.toInt() + } + + private fun coerceInt64(value: Any, featureId: String, memberName: String): Int64? = when (value) { + is Int64 -> value + is Int -> Int64(value.toLong()) + is Long -> Int64(value) + is Short -> Int64(value.toLong()) + is Byte -> Int64(value.toLong()) + is Double -> if (value.isFinite() && value == value.toLong().toDouble()) Int64(value.toLong()) else { warnMismatch(featureId, memberName, "int64", value); null } + is Float -> if (value.isFinite() && value == value.toLong().toFloat()) Int64(value.toLong()) else { warnMismatch(featureId, memberName, "int64", value); null } + else -> { warnMismatch(featureId, memberName, "int64", value); null } + } + + private fun coerceFloat32(value: Any, featureId: String, memberName: String): Float? = when (value) { + is Float -> value + is Double -> value.toFloat() + is Int -> value.toFloat() + is Long -> value.toFloat() + is Int64 -> value.toLong().toFloat() + is Short -> value.toFloat() + is Byte -> value.toFloat() + else -> { warnMismatch(featureId, memberName, "float32", value); null } + } + + private fun coerceFloat64(value: Any, featureId: String, memberName: String): Double? = when (value) { + is Double -> value + is Float -> value.toDouble() + is Int -> value.toDouble() + is Long -> value.toDouble() + is Int64 -> value.toLong().toDouble() + is Short -> value.toDouble() + is Byte -> value.toDouble() + else -> { warnMismatch(featureId, memberName, "float64", value); null } + } + + private fun coerceString(value: Any, featureId: String, memberName: String): String? = when (value) { + is String -> value + else -> { warnMismatch(featureId, memberName, "string", value); null } + } + + private fun coerceByteArray(value: Any, featureId: String, memberName: String): ByteArray? = when (value) { + is ByteArray -> value + else -> { warnMismatch(featureId, memberName, "byte_array", value); null } + } + + /** + * Coerce a spatial value to TWKB bytes. + * + * The expected input is a [SpGeometry] (which may be a [naksha.geo.SpPoint] or any other geometry subclass). + * The geometry is encoded as TWKB and returned as a [ByteArray]. + */ + private fun coerceSpatial(value: Any, featureId: String, memberName: String): ByteArray? = when (value) { + is SpGeometry -> toTWKB(value) + is ByteArray -> value + else -> { warnMismatch(featureId, memberName, "spatial", value); null } + } + + private fun coerceTags(value: Any, featureId: String, memberName: String): String? { + if (value !is AnyObject) { + warnMismatch(featureId, memberName, "tags", value) + return null + } + return try { toJSON(value) } catch (_: Exception) { warnMismatch(featureId, memberName, "tags", value); null } + } + + private fun coerceTagsFromArray(value: Any, featureId: String, memberName: String): String? { + val tagList = when (value) { + is TagList -> value + is AnyObject -> value.proxy(TagList::class) + else -> { warnMismatch(featureId, memberName, "tags_from_array", value); return null } + } + val tagMap = tagList.toTagMap() + return try { toJSON(tagMap) } catch (_: Exception) { warnMismatch(featureId, memberName, "tags_from_array", value); null } + } + + private fun numberToLongOrNull(value: Any): Long? = when (value) { + is Byte -> value.toLong() + is Short -> value.toLong() + is Int -> value.toLong() + is Long -> value + is Int64 -> value.toLong() + is Float -> if (value.isFinite() && value == value.toLong().toFloat()) value.toLong() else null + is Double -> if (value.isFinite() && value == value.toLong().toDouble()) value.toLong() else null + else -> null + } + + private fun warnMismatch(featureId: String, memberName: String, expected: String, value: Any) { + logger.warn("Member '$memberName' on feature '$featureId': expected $expected, got ${value::class.simpleName}") + } +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FetchMode.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FetchMode.kt index 7c6ef9a5cc..d6f4b68902 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FetchMode.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/FetchMode.kt @@ -20,37 +20,37 @@ inline fun FetchMode(vararg modes: Int): FetchMode { } /** - * Set the [members][Tuple.members] bit. + * Set the [members][Tuple.membersBook] bit. * @since 3.0.0 */ inline fun FetchMode.withMeta(): Int = this or META_BIT /** - * Clear the [members][Tuple.members] bit. + * Clear the [members][Tuple.membersBook] bit. * @since 3.0.0 */ inline fun FetchMode.noMeta(): Int = this and META_CLEAR /** - * Test if the [members][Tuple.members] bit is set. + * Test if the [members][Tuple.membersBook] bit is set. * @since 3.0.0 */ inline fun FetchMode.fetchMeta(): Boolean = (this and META_BIT) == META_BIT /** - * Set the _feature_ bit (covers [feature][Tuple.feature] and tags). + * Set the _feature_ bit (covers [feature][Tuple.featureBytes] and tags). * @since 3.0.0 */ inline fun FetchMode.withFeature(): Int = this or FEATURE_BIT /** - * Clear the _feature_ bit (covers [feature][Tuple.feature] and tags). + * Clear the _feature_ bit (covers [feature][Tuple.featureBytes] and tags). * @since 3.0.0 */ inline fun FetchMode.noFeature(): Int = this and FEATURE_CLEAR /** - * Test if the _feature_ bit is set (which covers [feature][Tuple.feature] and tags). + * Test if the _feature_ bit is set (which covers [feature][Tuple.featureBytes] and tags). * @since 3.0.0 */ inline fun FetchMode.fetchFeature(): Boolean = (this and FEATURE_BIT) == FEATURE_BIT diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Guid.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Guid.kt index 448dfcb37c..6fce9329eb 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Guid.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Guid.kt @@ -5,6 +5,7 @@ package naksha.model import naksha.base.Platform.PlatformCompanion.decodeURIComponent import naksha.base.Platform.PlatformCompanion.encodeURIComponent import naksha.model.objects.NakshaFeature +import naksha.model.objects.StandardMembers import kotlin.js.JsExport import kotlin.js.JsName import kotlin.js.JsStatic @@ -16,7 +17,7 @@ import kotlin.jvm.JvmStatic * * When [toString] is invoked, it is serialized into a [URN](https://datatracker.ietf.org/doc/html/rfc8141). It can be restored from a [URN](https://datatracker.ietf.org/doc/html/rfc8141) using the static helper [fromString]. The format of the URN is: * - * `urn:naksha:guid:{feature-id}:{storage-number}:{map-number}:{collection-number}:{feature-number}:{version}` + * `urn:naksha:guid:{feature-id}:{database-number}:{catalog-number}:{collection-number}:{feature-number}:{version}` * * The [Guid] is exposed through the [XYZ namespace][XyzNs] in the [uuid][XyzNs.uuid] property. * @since 3.0.0 @@ -118,9 +119,6 @@ data class Guid( */ @JsStatic @JvmStatic - fun fromTuple(tuple: Tuple): Guid { - val id = tuple.getStringMember(naksha.model.objects.StandardMembers.Id) ?: tuple.featureNumber.toString() - return Guid(id, tuple.tupleNumber) - } + fun fromTuple(tuple: Tuple): Guid = Guid(tuple.id, tuple.tupleNumber) } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IMemberProcessor.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IMemberProcessor.kt new file mode 100644 index 0000000000..d534f6b9ee --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IMemberProcessor.kt @@ -0,0 +1,23 @@ +package naksha.model + +import naksha.model.objects.Member +import naksha.model.objects.NakshaCollection +import naksha.model.objects.NakshaFeature + +/** + * Optional callback, invoked by the storage after the value of a member has been extracted. This allows some business logic to mutate the value before it is actually persisted. + */ +fun interface IMemberProcessor { + /** + * Invoked by the storage before it persists a feature. + * + * Beware that the `session`, `feature` and `collection` **must** be treated as read-only and **must not** be modified, even while it is technically possible. Modification of the objects is strictly forbidden for this method, doing so anyway will cause undefined behavior and may lead to data loss. The method **must** only read the given arguments and generate the result or just return the given value. + * @param session The current session as context. + * @param collection The collection in which the feature is located. + * @param feature The feature being processed _(the member is still in the feature in the original value)_. + * @param member The member that is processed. + * @param value The value that is about to be extracted from the feature, and to be persisted in a dedicated storage slot. + * @return the value that should be stored, can be just the same as the given one or any other acceptable primitive. + */ + fun processMember(session: ISession, collection: NakshaCollection, feature: NakshaFeature, member: Member, value: Any?): Any? +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IMetadataArray.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IMetadataArray.kt deleted file mode 100644 index 4f292193d8..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IMetadataArray.kt +++ /dev/null @@ -1,21 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model - -import kotlin.js.JsExport - -/** - * An interface to an array of [Metadata]. - */ -@JsExport -interface IMetadataArray { - /** - * The amount of entries. - */ - val size: Int - - /** - * Returns the [Metadata] at the given index. - */ - operator fun get(index: Int): Metadata -} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/ISession.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/ISession.kt index 8d52156e48..c75dc664f7 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/ISession.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/ISession.kt @@ -3,10 +3,11 @@ package naksha.model import naksha.model.objects.NakshaCollection -import naksha.model.objects.NakshaMap +import naksha.model.objects.NakshaCatalog import naksha.model.request.* import kotlin.js.JsExport import kotlin.js.JsName +import kotlin.jvm.JvmOverloads /** * When a session is opened, it is bound to the context in which the session shall operate. @@ -49,6 +50,15 @@ interface ISession : AutoCloseable { */ val options: SessionOptions + /** + * Returns the [MemberProcessorMap] for this session. + * + * Use the map to register, remove, or inspect [IMemberProcessor] instances for individual member processing. Processors are invoked in the order in which they were added. + * @return the member processor map. + * @since 3.0 + */ + val processors: MemberProcessorMap + // TODO: Define a streaming API (full table scan) to consume all features from a collection. // This API is designed to backup data, or to execute a read request with a huge cardinality, // therefore it should always operate on snapshots (specific versions). @@ -101,58 +111,46 @@ interface ISession : AutoCloseable { /** * Returns the map for the given identifier. * - * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return maps that were created in the current session, but beware that these maps may eventually fail to commit. - * @param mapId the map-id for which to return the latest HEAD state. - * @return the map; _null_ if no such map exists. + * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return catalogs that were created in the current session, but beware that these catalogs may eventually fail to commit. + * @param catalogId the catalog-id for which to return the latest HEAD state. + * @return the catalog; _null_ if no such catalog exists. * @since 3.0 */ - fun getMapById(mapId: String): NakshaMap? + fun getCatalogById(catalogId: String): NakshaCatalog? /** - * Returns the map for the given number. + * Returns the catalog for the given number. * - * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return maps that were created in the current session, but beware that these maps may eventually fail to commit. - * @param mapNumber the map-number for which to return the latest HEAD state. - * @return the map; _null_ if no such map exists. + * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return catalogs that were created in the current session, but beware that these catalogs may eventually fail to commit. + * @param catalogNumber the catalog-number for which to return the latest HEAD state. + * @return the catalog; _null_ if no such catalog exists. * @since 3.0 */ - fun getMapByNumber(mapNumber: Int): NakshaMap? + fun getCatalogByNumber(catalogNumber: Int): NakshaCatalog? /** * Returns the collection for the given identifier. * - * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return maps that were created in the current session, but beware that these collections may eventually fail to commit. - * @param map the map to query. + * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return collections that were created in the current session, but beware that these collections may eventually fail to commit. + * @param catalog the catalog to query. * @param collectionId the collection-id for which to return the latest HEAD state. * @return the collection; _null_ if no such collection exists. * @since 3.0 */ - fun getCollectionById(map: NakshaMap, collectionId: String): NakshaCollection? + fun getCollectionById(catalog: NakshaCatalog, collectionId: String): NakshaCollection? /** * Returns the collection for the given number. * - * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return maps that were created in the current session, but beware that these collections may eventually fail to commit. - * @param map the map to query. + * This method does only access the internal caching, and may not be up-to-date. If invoked on a [write session][IWriteSession] before committing changes, it will return collections that were created in the current session, but beware that these collections may eventually fail to commit. + * @param catalog the catalog to query. * @param collectionNumber the collection-number for which to return the latest HEAD state. * @return the collection; _null_ if no such collection exists. * @since 3.0 */ - fun getCollectionByNumber(map: NakshaMap, collectionNumber: Int): NakshaCollection? - - /** - * Load all tuples into the given [feature-tuples][FeatureTuple]. - * - * [Tuple] that can't be fetched will still be `null` after the method returns. The method should query the [Naksha.cache] before actually loading the [Tuple] from the storage _(without asking the cache to load from storage, otherwise this would be a recursion)_ . - * - * @param featureTuples a list of result-tuples to fetch. - * @since 3.0 - * @see [Naksha.cache] - */ - @JsName("loadAllTuples") - fun loadTuples(featureTuples: List) + fun getCollectionByNumber(catalog: NakshaCatalog, collectionNumber: Int): NakshaCollection? - /** + /** * Load all tuples into the given [feature-tuples][FeatureTuple]. * * [Tuple] that can't be fetched will still be `null` after the method returns. The method should query the [Naksha.cache] before actually loading the [Tuple] from the storage _(without asking the cache to load from storage, otherwise this would be a recursion)_ . @@ -160,14 +158,8 @@ interface ISession : AutoCloseable { * @param featureTuples a list of result-tuples to fetch. * @param from the index of the first result-tuples to fetch; default is `0`. * @param to the index of the first result-tuples to ignore; default is `featureTuples.size`. - * @param mode the fetch mode; default is [FETCH_ALL]. * @since 3.0 * @see [Naksha.cache] */ - fun loadTuples( - featureTuples: List, - from: Int = 0, - to: Int = featureTuples.size, - mode: FetchMode = FETCH_ALL - ) + fun loadTuples(featureTuples: List, from: Int = 0,to: Int = featureTuples.size) } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IStorage.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IStorage.kt index be78135d70..2ad3e6e2b2 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IStorage.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/IStorage.kt @@ -13,9 +13,9 @@ import kotlin.js.JsExport /** * Any entity implementing the [IStorage] interface represents some data-sink, and comes with an implementation that grants access to the data. The storage normally is a singleton that opens many sessions in parallel. * - * Storages operate on maps. All storages do have an [administrative map][Naksha.ADMIN_MAP], which can be virtual or real, implementation dependent. In this admin-map the storage exposes and manages the custom maps it stores, the transaction-logs of the storage, and the global dictionaries needed for the [JBON](https://github.com/heremaps/naksha/blob/v3/docs/JBON.md), plus optional implementation specific information. + * Storages operate on maps. All storages do have an [administrative map][Naksha.ADMIN_CATALOG_ID], which can be virtual or real, implementation dependent. In this admin-map the storage exposes and manages the custom maps it stores, the transaction-logs of the storage, and the global dictionaries needed for the [JBON](https://github.com/heremaps/naksha/blob/v3/docs/JBON.md), plus optional implementation specific information. * - * All other maps are custom maps, which are isolated data sinks within the same storage (like an own database schema, an own S3 bucket, an own SQLite database, an own directory or file, aso.). Each custom map is a fully separated storage entity. Within each custom map one [virtual admin collection][Naksha.COLLECTIONS_COL] is exposed, which can be used to manage the collections in the map. Some storages allow to access multiple maps from one session, others may limit a session to a single map, and will reject cross map operations with [NakshaError.UNSUPPORTED_OPERATION]. + * All other maps are custom maps, which are isolated data sinks within the same storage (like an own database schema, an own S3 bucket, an own SQLite database, an own directory or file, aso.). Each custom map is a fully separated storage entity. Within each custom map one [virtual admin collection][Naksha.COLLECTIONS_COL_ID] is exposed, which can be used to manage the collections in the map. Some storages allow to access multiple maps from one session, others may limit a session to a single map, and will reject cross map operations with [NakshaError.UNSUPPORTED_OPERATION]. * * The storage will cache the dictionaries to avoid that just for [Tuple] decoding a new session need to be opened, which would require object creation for every single feature being decoded, therefore every storage implements the [IDictReader] interface, which internally should be attached to a storage local cache, that is automatically kept up-to-date. The same cache can be accessed from every [session][ISession], because every [session][ISession] implements as well the [dictionary-reader interface][IDictReader]. * @@ -72,6 +72,10 @@ interface IStorage : IDictReader { */ val hardCap: Int + // TODO: fun createDatabase(databaseId: String): NakshaDatabase + // fun upgradeDatabase(database: NakshaDatabase) + // fun deleteDatabase(database: NakshaDatabase) + /** * Open a new write session. * @@ -80,6 +84,7 @@ interface IStorage : IDictReader { * @return the write session. * @since 2.0.7 */ + // TODO: Modify: fun newWriteSession(database: NakshaDatabase, options: SessionOptions? = null): IWriteSession fun newWriteSession(options: SessionOptions? = null): IWriteSession /** @@ -88,6 +93,7 @@ interface IStorage : IDictReader { * @param lambda the lambda to execute in a try block, ensuring that the session is closed. * @return the result of the lambda. */ + // TODO: Modify: fun useWriteSession(database: NakshaDatabase, options: SessionOptions? = null, lambda: Fn1): T fun useWriteSession(options: SessionOptions? = null, lambda: Fn1): T { val session = newWriteSession(options) return session.use { lambda.call(session) } @@ -99,6 +105,7 @@ interface IStorage : IDictReader { * @param options the session-options. * @param lambda the void lambda to execute in a try block, ensuring that the session is closed. */ + // TODO: Modify: fun runInWriteSession(database: NakshaDatabase, options: SessionOptions? = null, lambda: Fx1): T fun runInWriteSession(options: SessionOptions? = null, lambda: Fx1) { val session = newWriteSession(options) session.use { lambda.call(session) } @@ -112,6 +119,7 @@ interface IStorage : IDictReader { * @return the read-only session. * @since 2.0.7 */ + // TODO: Modify: fun newReadSession(database: NakshaDatabase, options: SessionOptions? = null): IReadSession fun newReadSession(options: SessionOptions? = null): IReadSession /** @@ -120,6 +128,7 @@ interface IStorage : IDictReader { * @param lambda the lambda to execute in a try block, ensuring that the session is closed. * @return the result of the lambda. */ + // TODO: Modify: fun useReadSession(database: NakshaDatabase, options: SessionOptions? = null, useReadSession): T fun useReadSession(options: SessionOptions? = null, lambda: Fn1): T { val session = newReadSession(options) return session.use { lambda.call(session) } @@ -131,19 +140,9 @@ interface IStorage : IDictReader { * @param options the session-options. * @param lambda the void lambda to execute in a try block, ensuring that the session is closed. */ + // TODO: Modify: fun runInReadSession(database: NakshaDatabase, options: SessionOptions? = null, lambda: Fx1): T fun runInReadSession(options: SessionOptions? = null, lambda: Fx1) { val session = newReadSession(options) session.use { lambda.call(session) } } - - /** - * The best feature encoding for the given feature. - * - * - Throws [NakshaError.UNINITIALIZED], if the storage failed to initialize. - * @param feature the feature to encode; _null_ if no specific one is available. - * @param context the context in which the encoding happens (for example the [map][naksha.model.objects.NakshaMap] or [collection][naksha.model.objects.NakshaCollection]); _null_ if none is available. - * @return best [DataEncoding] to use. - * @since 3.0 - */ - fun getDataEncoding(feature: Any?, context: Any? = null): DataEncoding } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/LibModel.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/LibModel.kt index 38fb53413f..e909aeddb8 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/LibModel.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/LibModel.kt @@ -77,7 +77,7 @@ val LATENCY_MEMORY = Int64(0) val DEFAULT_DATA_ENCODING = Naksha.DEFAULT_DATA_ENCODING /** - * The [members][Tuple.members] bit. + * The [members][Tuple.membersBook] bit. * @since 3.0.0 */ const val META_BIT: FetchMode = 1 @@ -101,7 +101,7 @@ const val GEOMETRY_BIT: FetchMode = 2 const val GEOMETRY_CLEAR: FetchMode = GEOMETRY_BIT.inv() /** - * The _feature_ bit, covers [feature][Tuple.feature] and tags. + * The _feature_ bit, covers [feature][Tuple.featureBytes] and tags. * @since 3.0.0 */ const val FEATURE_BIT: FetchMode = 4 diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/MemberProcessorList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/MemberProcessorList.kt new file mode 100644 index 0000000000..72a90f44bc --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/MemberProcessorList.kt @@ -0,0 +1,25 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model + +import kotlin.js.JsExport + +/** + * An ordered list of [IMemberProcessor] instances registered for a single member name. + * + * Processors are invoked in the order in which they were added. This class extends [ArrayList] + * to allow direct iteration and indexed access. + * @since 3.0 + */ +@JsExport +open class MemberProcessorList(private val delegate: MutableList = ArrayList()) : MutableList by delegate { + /** + * Create a shallow copy of this list. + * @return the shallow copy. + */ + fun copy(): MemberProcessorList { + val copy = MemberProcessorList() + for (e in delegate) copy.add(e) + return copy + } +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/MemberProcessorMap.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/MemberProcessorMap.kt new file mode 100644 index 0000000000..f19b0e4243 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/MemberProcessorMap.kt @@ -0,0 +1,231 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model + +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * A mutable map from member name to the list of [IMemberProcessor] instances registered for that member. + * + * The map is backed by a [MutableMap] and provides additional convenience methods: + * - [addProcessor] — adds a processor for a member (skips if already present) + * - [removeProcessor] — removes a processor for a member + * + * Processors are stored in a [MemberProcessorList] and invoked in insertion order. + * @since 3.0 + */ +@JsExport +class MemberProcessorMap : MutableMap { + + private val delegate: MutableMap = mutableMapOf() + + // ------------------------------------------------------------------------- + // Convenience methods + // ------------------------------------------------------------------------- + + /** + * Add a processor for the member with the given name. + * + * If the processor is already registered for this member, the call is a no-op. Processors are invoked in the order in which they were added. + * @param name The name of the member as specified in the [NakshaCollection][naksha.model.objects.NakshaCollection]. + * @param processor The processor to add. + * @return this. + */ + @JsName("addProcessor") + fun addProcessor(name: String, processor: IMemberProcessor): MemberProcessorMap { + var list = delegate[name] + if (list == null) { + list = MemberProcessorList() + delegate[name] = list + } + if (!list.contains(processor)) { + list.add(processor) + } + return this + } + + /** + * Add a processor for the given member. + * + * If the processor is already registered for this member, the call is a no-op. Processors are invoked in the order in which they were added. + * @param member The member for which to add a processor. + * @param processor The processor to add. + * @return this. + */ + @JsName("addMemberProcessor") + fun addProcessor(member: Member, processor: IMemberProcessor): MemberProcessorMap = addProcessor(member.name, processor) + + /** + * Add multiple processor for the member with the given name. + * + * If a processor is already registered for this member, the call is a no-op. Processors are invoked in the order in which they were added. + * @param name The name of the member as specified in the [NakshaCollection][naksha.model.objects.NakshaCollection]. + * @param processors The processors to add. + * @return this. + */ + @JsName("addProcessors") + fun addProcessors(name: String, processors: List): MemberProcessorMap { + if (processors.isEmpty()) return this + var list = delegate[name] + if (list == null) { + list = MemberProcessorList() + delegate[name] = list + } + for (processor in processors) { + if (!list.contains(processor)) { + list.add(processor) + } + } + return this + } + + /** + * Add multiple processor for the member with the given name. + * + * If a processor is already registered for this member, the call is a no-op. Processors are invoked in the order in which they were added. + * @param member The member for which to add processors. + * @param processors The processors to add. + * @return this. + */ + @JsName("addMemberProcessors") + fun addProcessors(member: Member, processors: List): MemberProcessorMap = addProcessors(member.name, processors) + + /** + * Remove a processor for the member with the given name. + * + * If the list becomes empty after removal, the entry is removed from the map. + * @param name The name of the member as specified in the [NakshaCollection][naksha.model.objects.NakshaCollection].. + * @param processor The processor to remove. + * @return _true_ if the processor was found and removed, _false_ otherwise. + */ + @JsName("removeProcessor") + fun removeProcessor(name: String, processor: IMemberProcessor): Boolean { + val list = delegate[name] ?: return false + val removed = list.remove(processor) + if (list.isEmpty()) { + delegate.remove(name) + } + return removed + } + + /** + * Remove a processor for the given member. + * + * If the list becomes empty after removal, the entry is removed from the map. + * @param member The member for which to remove the processor. + * @param processor The processor to remove. + * @return _true_ if the processor was found and removed, _false_ otherwise. + */ + @JsName("removeMemberProcessor") + fun removeProcessor(member: Member, processor: IMemberProcessor): Boolean = removeProcessor(member.name, processor) + + /** + * Returns the list of processors for the member with the given name, or `null` if none are registered. + * + * Mutation of the returned list will modify the processor map too. + * @param name The member name. + * @return the processor list or `null`, if no processor for the given name is added. + */ + @JsName("getProcessors") + fun getProcessors(name: String): MemberProcessorList? = delegate[name] + + /** + * Returns the list of processors for the member or `null` if none are registered. + * + * Mutation of the returned list will modify the processor map too. + * @param member The member. + * @return the processor list or `null`, if no processor for the given member is added. + */ + @JsName("getMemberProcessors") + fun getProcessors(member: Member): MemberProcessorList? = delegate[member.name] + + // ------------------------------------------------------------------------- + // MutableMap delegation + // ------------------------------------------------------------------------- + + override val size: Int + get() = delegate.size + + override fun isEmpty(): Boolean = delegate.isEmpty() + + override fun containsKey(key: String): Boolean = delegate.containsKey(key) + + override fun containsValue(value: MemberProcessorList): Boolean = delegate.containsValue(value) + + override fun get(key: String): MemberProcessorList? = delegate[key] + + override fun put(key: String, value: MemberProcessorList): MemberProcessorList? = delegate.put(key, value) + + override fun remove(key: String): MemberProcessorList? = delegate.remove(key) + + override fun putAll(from: Map) { + delegate.putAll(from) + } + + override fun clear() { + delegate.clear() + } + + override val keys: MutableSet + get() = delegate.keys + + override val values: MutableCollection + get() = delegate.values + + override val entries: MutableSet> + get() = delegate.entries + + /** + * Returns `true` if there are no processors registered for any member. + */ + fun isEmptyProcessors(): Boolean = delegate.isEmpty() + + /** + * Create a backup of the current processor map, useful when temporary different processors are needed. + * @param clear if the map should be cleared after backup, makes the backup faster. + * @return the backup of the processor map. + * @since 3.0 + */ + fun backup(clear: Boolean = true): Map { + val backup = mutableMapOf() + for ((key, value) in delegate.entries) { + // When we clear, we can just copy the reference, otherwise we need a copy of the MemberList. + if (clear) backup[key] = value else backup[key] = value.copy() + } + if (clear) delegate.clear() + return backup + } + + /** + * Restores a backup, useful when temporary different processors are needed. + * @param backup the backup to restore. + * @param clear if the map should be cleared, before restoring. + * @param consume if the backup should be consumed, then only references need to be copied. + * @return this. + */ + fun restore(backup: Map, clear: Boolean = true, consume: Boolean = false): MemberProcessorMap { + if (clear) delegate.clear() + for ((key, backupList) in backup) { + if (clear && consume) { + // We can copy the reference, the backup is not need anymore anyway. + delegate[key] = backupList + } else if (clear) { + // Copy from backup, because the backup should stay intact and may be reused. + // If we would not do this, any modification after restore would modify the backup too! + delegate[key] = backupList.copy() + } else { // no clear, no consume + val existing = delegate[key] + if (existing == null) { + if (consume) delegate[key] = backupList else delegate[key] = backupList.copy() + } else { + for (e in backupList) { + if (!existing.contains(e)) existing.add(e) + } + } + } + } + return this + } +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Metadata.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Metadata.kt deleted file mode 100644 index 876a768f61..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Metadata.kt +++ /dev/null @@ -1,277 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model - -import naksha.base.Fnv1a32 -import naksha.base.Int64 -import naksha.base.Platform -import naksha.base.fn.Fn3 -import naksha.geo.HereTile -import naksha.model.objects.NakshaFeature -import naksha.model.objects.StandardMembers -import kotlin.concurrent.Volatile -import kotlin.js.JsExport -import kotlin.js.JsStatic -import kotlin.jvm.JvmField -import kotlin.jvm.JvmStatic - -/** - * The immutable on-heap representation of the metadata of a [Tuple]. - * - * This is mainly used by applications, therefore the default value of [tupleNumber] is [TupleNumber.HEAD]. - * @since 3.0.0 - */ -@JsExport -data class Metadata( - val tupleNumber: TupleNumber = TupleNumber.HEAD, - val dataEncoding: DataEncoding = DataEncoding.DEFAULT, - val updatedAt: Int64 = Platform.currentMillis(), - val createdAt: Int64? = null, - val authorTs: Int64? = null, - val nextVersion: Int64? = null, - val baseTupleNumber: TupleNumber? = null, - val changeCount: Int = 1, - val hash: Int = 0, - val hereTile: Int = 0, - val id: String, - val appId: String = NakshaContext.appId(), - val author: String? = NakshaContext.author(), - val origin: String? = null, - val target: String? = null, - val ft: String? = null, - val cv0: Double? = null, - val cv1: Double? = null, - val cv2: Double? = null, - val cv3: Double? = null, - val cs0: String? = null, - val cs1: String? = null, - val cs2: String? = null, - val cs3: String? = null, -) { - val storageNumber: Int64 - get() = tupleNumber.storageNumber - val mapNumber: Int - get() = tupleNumber.mapNumber - val collectionNumber: Int - get() = tupleNumber.collectionNumber - val featureNumber: Int64 - get() = tupleNumber.featureNumber - val partitionNumber: Int - get() = tupleNumber.partitionNumber - val version: Version - get() = tupleNumber.version - val txn: Int64 - get() = version.txn - - /** - * Tests if this describes a new state. - * @return _true_ if this describes a new state, not yet persisted; _false_, if it describes an existing state. - */ - fun isNew(): Boolean = tupleNumber == TupleNumber.HEAD - - @Volatile - private var _guid: Guid? = null - - /** - * Returns the [Guid]. - * @return the [Guid]. - */ - val guid: Guid - get() { - var guid = _guid - if (guid == null) { - guid = Guid(id, tupleNumber) - _guid = guid - } - return guid - } - - @Volatile - private var _originGuid: Guid? = null - - val originGuid: Guid? - get() { - var guid = _originGuid - if (guid == null) { - val origin = this.origin ?: return null - try { - guid = Guid.fromString(origin) - } catch (e: Exception) { - return null - } - _originGuid = guid - } - return guid - } - - @Volatile - private var _targetGuid: Guid? = null - - val targetGuid: Guid? - get() { - var guid = _targetGuid - if (guid == null) { - val target = this.target ?: return null - try { - guid = Guid.fromString(target) - } catch (e: Exception) { - return null - } - _targetGuid = guid - } - return guid - } - - /** - * Returns the action encoded in the lower two bits of [Version.txn]. - */ - fun action() : Action = Action.fromValue((version.txn.toInt()) and 3) - - override fun hashCode(): Int = tupleNumber.hashCode() - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Metadata) return false - return guid == other.guid - } - override fun toString(): String = "$id:$tupleNumber" - - companion object Metadata_C { - - /** - - * Import other metadata into the heap representation. - * @param other the other metadata. - * @since 3.0.0 - */ - @JvmStatic - @JsStatic - fun fromOther(other: Any?): Metadata? { - if (other is Metadata) return other - if (other is Tuple) { - return Metadata( - tupleNumber = other.tupleNumber, - dataEncoding = other.dataEncoding, - updatedAt = other.getLongMember(StandardMembers.UpdatedAt), - createdAt = other.getLongMember(StandardMembers.CreatedAt), - authorTs = other.getLongMember(StandardMembers.AuthorTimestamp), - nextVersion = if (other.nextVersion == Int64(-1L)) null else other.nextVersion, - baseTupleNumber = null, - changeCount = other.getIntMember(StandardMembers.ChangeCount), - hash = other.getIntMember(StandardMembers.Hash), - hereTile = other.getIntMember(StandardMembers.HereTile), - id = other.getStringMember(StandardMembers.Id) ?: "undefined", - appId = other.getStringMember(StandardMembers.AppId) ?: NakshaContext.appId(), - author = other.getStringMember(StandardMembers.Author), - origin = other.getStringMember(StandardMembers.Origin), - target = other.getStringMember(StandardMembers.Target), - ft = other.getStringMember(StandardMembers.FeatureType), - cv0 = other.getDoubleMember(StandardMembers.CustomValue0), - cv1 = other.getDoubleMember(StandardMembers.CustomValue1), - cv2 = other.getDoubleMember(StandardMembers.CustomValue2), - cv3 = other.getDoubleMember(StandardMembers.CustomValue3), - cs0 = other.getStringMember(StandardMembers.CustomString0), - cs1 = other.getStringMember(StandardMembers.CustomString1), - cs2 = other.getStringMember(StandardMembers.CustomString2), - cs3 = other.getStringMember(StandardMembers.CustomString3), - ) - } - return null - } - - /** - * Creates the [Metadata] from the given [XYZ namespace][XyzNs]. - * - * If the given [XYZ namespace][XyzNs] is not from an existing, really stored feature, then the method returns _null_, what means, that the feature to which this [XYZ namespace][XyzNs] is attached is a client modified version. - * @param featureId the **feature-id**. - * @param featureType the **feature-type**. - * @param xyz the [XYZ namespace][XyzNs]. - * @return the [Metadata] created from it. - * @since 3.0.0 - * @see [XyzNs.fromMetadata] - */ - @JvmStatic - @JsStatic - fun fromXyzNs(featureId: String, featureType: String, xyz: XyzNs): Metadata? { - val guid = xyz.guid ?: return null - return Metadata( - tupleNumber = guid.tupleNumber, - nextVersion = xyz.nguid?.tupleNumber?.version?.txn, - baseTupleNumber = xyz.mguid?.tupleNumber, - dataEncoding = xyz.dataEncoding ?: DataEncoding.DEFAULT, - updatedAt = xyz.updatedAt, - createdAt = if (xyz.updatedAt == xyz.createdAt) null else xyz.createdAt, - authorTs = if (xyz.updatedAt == xyz.authorTs) null else xyz.authorTs, - hash = xyz.hash ?: 0, - changeCount = xyz.changeCount, - hereTile = xyz.hereTile ?: 0, // TODO: Fix me, update! - appId = xyz.appId, - author = xyz.author, - id = featureId, - origin = xyz.origin, - target = xyz.target, - ft = featureType, - cv0 = xyz.cv0, cv1 = xyz.cv1, cv2 = xyz.cv2, cv3 = xyz.cv3, - cs0 = xyz.cs0, cs1 = xyz.cs1, cs2 = xyz.cs2, cs3 = xyz.cs3, - ) - } - - /** - * The undefined metadata singleton. - * @since 3.0. - */ - @JvmField - @JsStatic - val UNDEFINED = Metadata( - tupleNumber = TupleNumber.HEAD, - dataEncoding = DataEncoding.DEFAULT, - updatedAt = Int64(0), - hash = 0, - changeCount = 0, - hereTile = 0, - id = "undefined", - appId = "undefined", - author = null, - ) - - /** - * Calculates the feature hash to be stored in [Metadata]. - * @param feature the feature. - * @param excludePaths an optional list of paths to exclude. - * @param excludeFn an optional function to call for the [feature], current path, current value to decide if the value should be excluded from hashing. - * @return the hash. - */ - @Suppress("UNUSED_PARAMETER") - @JvmStatic - @JsStatic - fun calculateHash( - feature: NakshaFeature, - excludePaths: List>? = null, - excludeFn: Fn3, Any?>? = null - ): Int { - // TODO: We need to calculate the hash above the feature itself. - // - Order keys first. - // - Exclude the given paths - // - Always exclude ["properties", "@ns:com:here:xyz"] - // - The purpose of the hash is to find similar entries - // - We only care about real data changes (not times, author, other metadata) - return Fnv1a32.string(0, feature.id) - } - - /** - * Calculate the HERE tile-id to be stored in [Metadata]. - * @param feature the feature for which to calculate the HERE tile-id. - * @return the HERE tile-id _(aka the int-key)_. - */ - @JvmStatic - @JsStatic - fun calculateHereTile(feature: NakshaFeature): Int { - val c = feature.referencePoint ?: feature.geometry?.calculateCentroid() - return if (c != null) HereTile(c.latitude, c.longitude).intKey else Fnv1a32.string(0, feature.id) - } - } - - // TODO: toByteArray - we have a binary encoding already in PgTupleLoader, move here - // fromByteArray - we have a binary encoding already in PgTupleLoader, move here - // maybe it is better to realize this with a MetadataByteArray (basically extract code from PgTupleLoader) -} - diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Naksha.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Naksha.kt index ba73897ce2..9bea39feb4 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Naksha.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Naksha.kt @@ -4,19 +4,14 @@ package naksha.model import naksha.base.* import naksha.base.Platform.PlatformCompanion.fromJSON -import naksha.base.Platform.PlatformCompanion.gzipDeflate -import naksha.base.Platform.PlatformCompanion.gzipInflate import naksha.base.Platform.PlatformCompanion.md5 import naksha.base.Platform.PlatformCompanion.toJSON import naksha.geo.GeoUtil.GeoUtil_C.fromTWKB import naksha.geo.GeoUtil.GeoUtil_C.toTWKB import naksha.geo.SpGeometry -import naksha.jbon.* import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT import naksha.model.NakshaError.NakshaErrorCompanion.STORAGE_NOT_FOUND import naksha.model.NakshaVersion.Companion.CURRENT -import naksha.model.objects.NakshaFeature -import naksha.model.objects.NakshaProperties import naksha.model.objects.NakshaStorage import kotlin.js.JsExport import kotlin.js.JsName @@ -40,73 +35,96 @@ class Naksha private constructor() { const val INTERNAL_PREFIX = "naksha~" /** - * The identifier of the administration map _(`naksha~admin`)_. + * The identifier of the administration catalog, fixed to `naksha~admin`. It can be found in any database under Naksha control. + * + * **Note**: The feature of the administration catalog is an immutable feature needed to bootstrap a Naksha controlled database, therefore it is not peristed anywhere. * @since 3.0 */ - const val ADMIN_MAP = "naksha~admin" + const val ADMIN_CATALOG_ID = "naksha~admin" /** - * The number of the administration map _(`0`)_. + * The identifier of the administration catalog, fixed to `0`. It can be found in any database under Naksha control. * @since 3.0 */ - const val ADMIN_MAP_NUMBER = 0 + const val ADMIN_CATALOG_FN = 0 /** - * The identifier of the virtual collection in which the collections of a map are managed, located within each map _(`naksha~collections`)_. + * The identifier of the collections-collection, the collection in which the collection-features of each catalog are persisted. + * + * This collection exists in every catalog under Naksha management. The identifier of the collection itself is fixed to `naksha~collections`. The feature of the collections-collection is an immutable feature. It is needed to bootstrap a new catalog. * @since 3.0 */ - const val COLLECTIONS_COL = "naksha~collections" + const val COLLECTIONS_COL_ID = "naksha~collections" /** - * The collection-number of the virtual collection in which the collections of a map are managed, located within each map _(`0`)_ . + * The collection-number of the collections-collection in which the collection-features of each catalog are persisted, it has the fixed feature-number _(`0`)_. * @since 3.0 */ - const val COLLECTIONS_COL_NUMBER = 0 + const val COLLECTIONS_COL_FN = 0 /** - * The identifier of the collection in which transactions are stored, located in the [admin-map][ADMIN_MAP] _(`naksha~transactions`)_. + * The identifier of the collection in which transactions are stored, located in the [admin-map][ADMIN_CATALOG_ID] _(`naksha~transactions`)_. * @since 3.0 * @see [naksha.model.objects.NakshaTx] */ - const val TRANSACTIONS_COL = "naksha~transactions" + const val TRANSACTIONS_COL_ID = "naksha~transactions" /** - * The collection-number of the collection in which transactions are stored, located in the [admin-map][ADMIN_MAP] _(`1`)_. + * The collection-number of the collection in which transactions are stored, located in the [admin-catalog][ADMIN_CATALOG_ID]. The feature-number of this collection is fixed to `1`. * @since 3.0 */ - const val TRANSACTIONS_COL_NUMBER = 1 + const val TRANSACTIONS_COL_FN = 1 /** - * The identifier of the collection in which catalogs (maps) are stored, located only within the [admin-map][ADMIN_MAP] _(`naksha~catalogs`)_. - * @see [naksha.model.objects.NakshaMap] + * The identifier of the collection in which catalogs (maps) are stored, located only within the [admin-map][ADMIN_CATALOG_ID] _(`naksha~catalogs`)_. + * @see [naksha.model.objects.NakshaCatalog] * @since 3.0 */ - const val CATALOGS_COL = "naksha~catalogs" + const val CATALOGS_COL_ID = "naksha~catalogs" /** - * The collection-number of the collection in which catalogs (maps) are stored, located in the [admin-map][ADMIN_MAP] _(`2`)_. + * The collection-number of the collection in which catalogs (maps) are stored, located in the [admin-map][ADMIN_CATALOG_ID] _(`2`)_. * @since 3.0 */ - const val CATALOGS_COL_NUMBER = 2 + const val CATALOGS_COL_FN = 2 /** - * The identifier of the collection in which books (global JBON2 dictionaries) are stored, located in the [admin-map][ADMIN_MAP] _(`naksha~books`)_. + * The identifier of the collection in which books (global JBON2 dictionaries) are stored, located in the [admin-map][ADMIN_CATALOG_ID] _(`naksha~books`)_. * @since 3.0 */ - const val BOOKS_COL = "naksha~books" + const val BOOKS_COL_ID = "naksha~books" /** - * The collection-number of the collection in which books (global JBON2 dictionaries) are stored, located in the [admin-map][ADMIN_MAP] _(`3`)_. + * The collection-number of the collection in which books (global JBON2 dictionaries) are stored, located in the [admin-map][ADMIN_CATALOG_ID] _(`3`)_. * @since 3.0 */ - const val BOOKS_COL_NUMBER = 3 + const val BOOKS_COL_FN = 3 /** - * The maximum length of identifiers _(`42`)_ . + * The maximum length of identifiers _(`42`)_. * @since 3.0 */ const val MAX_ID_LENGTH = 42 // The answer to everything ;-) + /** + * The maximum length of internal identifiers. + * @since 3.0 + */ + const val MAX_INTERNAL_ID_LENGTH = 63 + + // TODO: This shows why we really need a special TupleNumberArray next to TupleNumberList, which stores all tuple-numbers in a single + // byte-array, compressed by adding the database-number, catalog-number and collection-number upfront, then followed by all the + // tuple-numbers. It will reduce memory consumption from 1 GiB to around 256 MiB. + + /** + * The maximum amount of tuple that can be fetched using normal query methods. + * + * This protects the database and client for too big data. When reading tuples, each tuple-number is actually returned from the storage as 16-byte value, so feature-number and version. The Java client then adds database-number, catalog-number and collection-number. Therefore, the maximum amount of data transferred when fetching this amount of tuple is roughly `HARD_READ_LIMIT * 16`. This can already be huge, but when we copy this onto the JVM heap, we expand it, because we add the database-number _(8 byte)_, catalog-number _(4 byte)_ and collection-number _(4 byte)_ to it, plus the overhead of the [TupleNumber] instance (16-byte per instance). Then there is the array into which they are added, this array holds a reference for each [TupleNumber], so another 8-byte. In total, for JVM heap usage, we need to multiple this value with around 56 _(16+16+8+4+4+8)_. For the default value of 16,777,216 this already means around 1 GiB of heap usage, not even thinking about how much more memory will be used, when we start loading all these tuple! + * @since 3.0 + */ + @JvmStatic + var HARD_TUPLE_LIMIT = 16_777_216 + /** * An immutable map between the identifier of an internal collection to the number of that collection. * @since 3.0 @@ -114,11 +132,11 @@ class Naksha private constructor() { @JsStatic @JvmStatic val internalIdToNumber = mapOf( - Pair(ADMIN_MAP, ADMIN_MAP_NUMBER), - Pair(COLLECTIONS_COL, COLLECTIONS_COL_NUMBER), - Pair(TRANSACTIONS_COL, TRANSACTIONS_COL_NUMBER), - Pair(CATALOGS_COL, CATALOGS_COL_NUMBER), - Pair(BOOKS_COL, BOOKS_COL_NUMBER), + Pair(ADMIN_CATALOG_ID, ADMIN_CATALOG_FN), + Pair(COLLECTIONS_COL_ID, COLLECTIONS_COL_FN), + Pair(TRANSACTIONS_COL_ID, TRANSACTIONS_COL_FN), + Pair(CATALOGS_COL_ID, CATALOGS_COL_FN), + Pair(BOOKS_COL_ID, BOOKS_COL_FN), ) /** @@ -135,88 +153,6 @@ class Naksha private constructor() { @JvmField var DEFAULT_SESSION_LOG_LEVEL: String? = null - /** - * Tests if the given **id** is a valid identifier, so matches: - * - * `[a-z][a-z0-9_:-]{42}` - * - * **Beware**: Identifiers must not contain upper-case letters, because many storages does not make a difference between upper- and lower-cased letters. - * @param id the identifier. - * @return _true_ if the identifier is valid; _false_ otherwise. - * @since 3.0 - * @see [verifyId] - * @see [MAX_ID_LENGTH] - */ - @JsStatic - @JvmStatic - fun isValidId(id: String?): Boolean { - if (id.isNullOrEmpty() || "naksha" == id || id.length > MAX_ID_LENGTH) return false - var i = 0 - var c = id[i++] - // First character must be a-z - if (c.code < 'a'.code || c.code > 'z'.code) return false - while (i < id.length) { - c = id[i++] - when (c.code) { - in 'a'.code..'z'.code -> continue - in '0'.code..'9'.code -> continue - '_'.code, ':'.code, '-'.code -> continue - else -> return false - } - } - return true - } - - /** - * Tests if the given **id** is a valid identifier, so matches: - * - * `[a-z][a-z0-9_:-]{31}` - * - * If the given identifier is invalid, the methods throws [NakshaError.ILLEGAL_ID]. - * @param id the identifier to test. - * @return the given identifier, tested. - * @since 3.0 - * @see [isValidId] - */ - @JsStatic - @JvmStatic - fun verifyId(id: String?): String { - if (id.isNullOrEmpty()) { - throw illegalId("The given identifier is null or empty") - } - if (id == "naksha") { - throw illegalId("The identifier 'naksha' is forbidden") - } - if (id.length > MAX_ID_LENGTH) { - throw illegalId("The identifier '$id' is too long: ${id.length}, must be maximal $MAX_ID_LENGTH") - } - var i = 0 - var c = id[i++] - if (c.code < 'a'.code || c.code > 'z'.code) { - throw illegalId("The first character must be a-z, but was $c") - } - while (i < id.length) { - c = id[i++] - when (c.code) { - in 'a'.code..'z'.code -> continue - in '0'.code..'9'.code -> continue - '_'.code, ':'.code, '-'.code -> continue - else -> throw illegalId("Invalid character at index $i: '$c', expected [a-z0-9_:-]") - } - } - return id - } - - /** - * Tests if the given identifier is an internal one. - * @param id the identifier to test. - * @return _true_ if this is an internal identifier; _false_ otherwise. - * @since 3.0 - */ - @JsStatic - @JvmStatic - fun isInternalId(id: String?): Boolean = id != null && id.startsWith(INTERNAL_PREFIX) - /** * Generates an [MD5](https://en.wikipedia.org/wiki/MD5) hash above the given identifier, which is used to extract many values from it. * @param id the identifier to hash. @@ -229,28 +165,28 @@ class Naksha private constructor() { } /** - * A regular expression to test if a string contains potentially a 63-bit unsigned integer (`0 .. 9,223,372,036,854,775,807`). + * A regular expression to test if a string contains potentially a 63-bit unsigned integer (`1 .. 9,223,372,036,854,775,807`). * @since 3.0 */ private val is63BitUnsigned = Regex("^[1-9][0-9]{0,18}$") /** - * A regular expression to test if a string contains potentially a 31-bit unsigned integer (`0 .. 2,147,483,647`). + * A regular expression to test if a string contains potentially a 31-bit unsigned integer (`1 .. 2,147,483,647`). * @since 3.0 */ private val is31BitUnsigned = Regex("^[1-9][0-9]{0,9}\$") /** - * A method to calculate a valid storage-number from the storage-id. + * A method to calculate a valid database-number from the database-id. * - * @param id the id, from which to extract the storage-number. - * @return the storage-number. + * @param id the id, from which to extract the database-number. + * @return the database-number. * @since 3.0 * @see [hashId] */ @JsStatic @JvmStatic - fun storageNumber(id: String): Int64 { + fun databaseNumber(id: String): Int64 { if (id == "0" || is63BitUnsigned.matches(id)) { try { return id.toLong(10).toInt64() @@ -261,17 +197,17 @@ class Naksha private constructor() { } /** - * A method to calculate a valid map-number from the map-id. + * A method to calculate a valid catalog-number from the catalog-id. * - * @param id the map-id, from which to extract the map-number. - * @return the map-number. + * @param id the catalog-id, from which to extract the catalog-number. + * @return the catalog-number. * @since 3.0 * @see [hashId] */ @JsStatic @JvmStatic - fun mapNumber(id: String): Int { - if (id == ADMIN_MAP) return ADMIN_MAP_NUMBER + fun catalogNumber(id: String): Int { + if (id == ADMIN_CATALOG_ID) return ADMIN_CATALOG_FN if (id == "0" || is31BitUnsigned.matches(id)) { try { return id.toUInt(10).toInt() @@ -293,7 +229,7 @@ class Naksha private constructor() { @JvmStatic fun collectionNumber(id: String): Int { val internalNumber = internalIdToNumber[id] - if (id != ADMIN_MAP && internalNumber != null) return internalNumber + if (id != ADMIN_CATALOG_ID && internalNumber != null) return internalNumber if (id == "0" || is31BitUnsigned.matches(id)) { try { return id.toUInt(10).toInt() @@ -460,247 +396,6 @@ class Naksha private constructor() { @JvmStatic fun alternativeInt32(number: Int): Int = (number + 1) or -2147483648 - /** - * Decode the [Naksha feature][NakshaFeature] from the given [tuple][Tuple]. - * - * This method will query the [cache] to get the [dictionary-manager][IDictManager]. - * - Throws [NakshaError.DICT_MANAGER_NOT_FOUND], if a [dictionary-manager][IDictManager] is needed to decode the [Tuple], but not available in [cache]. - * @param tuple the tuple to decode. - * @param dictionaryReader the dictionary reader to use, if _null_, then the storage or cache are used. - * @return the Naksha feature, _null_ if decoding failed or _null_ was given. - * @since 3.0 - */ - @JsStatic - @JvmStatic - @JvmOverloads - fun decodeTuple(tuple: Tuple, dictionaryReader: IDictReader? = null): NakshaFeature { - val sn = tuple.storageNumber - val dictReader = dictionaryReader ?: getStorageByNumber(sn) ?: cache.getDictReader(sn) - val feature = decodeFeature(tuple.feature, dictReader) ?: NakshaFeature() - feature.properties.xyz = XyzNs.fromTuple(tuple) - val xyz = feature.properties.xyz - val tags = tuple.getTagList(naksha.model.objects.StandardMembers.Tags) - if (tags != null) xyz.tags = tags - val geo = tuple.getByteArray(naksha.model.objects.StandardMembers.Geometry) - if (geo != null) feature.geometry = decodeGeometry(geo) - return feature - } - - /** - * Encode the given [NakshaFeature] into a [Tuple]. - * @param feature the feature to encode. - * @param attachment the attachment to encode; if any. - * @param dictionary the [book][IBook] to use to encode the feature; _null_ if encoding should be done storage agnostic. - * @param dataEncoding the feature encoding to use, or _null_ to fall back to the storage default and finally [DEFAULT_DATA_ENCODING]. - * @return the encoded [Tuple]. - * @since 3.0 - * @see [IStorage.getEncodingDictionary] - */ - @JsStatic - @JvmStatic - @JvmOverloads - fun encodeTuple( - feature: NakshaFeature, - attachment: ByteArray? = null, - dictionary: IBook? = null, - dataEncoding: DataEncoding? = null - ): Tuple { - val xyz = feature.properties.xyz - val tn = feature.tupleNumber - val storage = getStorageByNumber(tn.storageNumber) - val encoding = dataEncoding ?: storage?.getDataEncoding(feature) ?: DEFAULT_DATA_ENCODING - val members = HeapBook() - members.put("id", feature.id) - members.put("app_id", xyz.appId) - members.put("updated_at", xyz.updatedAt) - members.put("created_at", if (xyz.updatedAt == xyz.createdAt) null else xyz.createdAt) - members.put("author_ts", if (xyz.updatedAt == xyz.authorTs) null else xyz.authorTs) - members.put("author", xyz.author) - members.put("data_encoding", encoding.toString()) - members.put("cc", xyz.changeCount) - members.put("hash", xyz.hash ?: 0) - members.put("here_tile", xyz.hereTile ?: 0) - members.put("ft", xyz.featureType) - members.put("cv0", xyz.cv0) - members.put("cv1", xyz.cv1) - members.put("cv2", xyz.cv2) - members.put("cv3", xyz.cv3) - members.put("cs0", xyz.cs0) - members.put("cs1", xyz.cs1) - members.put("cs2", xyz.cs2) - members.put("cs3", xyz.cs3) - val dict = dictionary ?: storage?.getDictionary(feature.id) - val featureBytes = encodeFeature(feature, encoding, dict) - val geoBytes = encodeGeometry(feature.geometry) - val refPoint = encodeGeometry(feature.referencePoint) - val tagsJson = encodeTagList(xyz.tags) - members.put("geo", geoBytes) - members.put("ref_point", refPoint) - members.put("tags", tagsJson) - members.put("attachment", attachment) - return Tuple( - storageNumber = tn.storageNumber, - mapNumber = tn.mapNumber, - collectionNumber = tn.collectionNumber, - featureNumber = tn.featureNumber, - version = tn.version, - nextVersion = Int64(-1L), - members = members, - feature = featureBytes - ) - } - - /** - * Encode the given [NakshaFeature] into a [Tuple] for the given [storage][IStorage]. - * - * @param feature the feature to encode. - * @param attachment the attachment to encode; if any. - * @param storage the [storage][IStorage] for which to encode the feature. - * @return the encoded [Tuple]. - * @since 3.0 - */ - @JsStatic - @JvmStatic - @JsName("encodeTupleForStorage") - fun encodeTuple( - feature: NakshaFeature, - attachment: ByteArray?, - storage: IStorage - ): Tuple { - val xyz = feature.properties.xyz - val tn = feature.tupleNumber - val dict = storage.getEncodingDictionary(feature) - val encoding = storage.getDataEncoding(feature) - val members = HeapBook() - members.put("id", feature.id) - members.put("app_id", xyz.appId) - members.put("updated_at", xyz.updatedAt) - members.put("created_at", if (xyz.updatedAt == xyz.createdAt) null else xyz.createdAt) - members.put("author_ts", if (xyz.updatedAt == xyz.authorTs) null else xyz.authorTs) - members.put("author", xyz.author) - members.put("data_encoding", encoding.toString()) - members.put("cc", xyz.changeCount) - members.put("hash", xyz.hash ?: 0) - members.put("here_tile", xyz.hereTile ?: 0) - members.put("ft", xyz.featureType) - members.put("cv0", xyz.cv0) - members.put("cv1", xyz.cv1) - members.put("cv2", xyz.cv2) - members.put("cv3", xyz.cv3) - members.put("cs0", xyz.cs0) - members.put("cs1", xyz.cs1) - members.put("cs2", xyz.cs2) - members.put("cs3", xyz.cs3) - val featureBytes = encodeFeature(feature, encoding, dict) - val geoBytes = encodeGeometry(feature.geometry) - val refPoint = encodeGeometry(feature.referencePoint) - val tagsJson = encodeTagList(xyz.tags) - members.put("geo", geoBytes) - members.put("ref_point", refPoint) - members.put("tags", tagsJson) - members.put("attachment", attachment) - return Tuple( - storageNumber = tn.storageNumber, - mapNumber = tn.mapNumber, - collectionNumber = tn.collectionNumber, - featureNumber = tn.featureNumber, - version = tn.version, - nextVersion = Int64(-1L), - members = members, - feature = featureBytes - ) - } - - /** - * Encodes the given [NakshaFeature] into bytes, skipping over the [geometry][NakshaFeature.geometry], and the [XYZ-namespace][XyzNs]. - * @param feature the feature to encode. - * @param encoding the feature encoding to use. - * @param dict the dictionary to use for encoding; if any. - * @return the encoded feature. - * @since 3.0 - */ - @JsStatic - @JvmStatic - fun encodeFeature(feature: NakshaFeature?, encoding: DataEncoding, dict: IBook?): ByteArray? { - if (feature.isNullOrEmpty()) return null - var byteArray: ByteArray? = when (encoding) { - DataEncoding.JSON, DataEncoding.JSON_GZIP -> { - // We do not want to encode geometry. - val f = feature.copy(false) - f.removeRaw(NakshaFeature.GEOMETRY) - // We do not want to encode properties.@ns:com:here:xyz. - val p = feature.properties.copy(false) - p.removeRaw(NakshaProperties.XYZ_KEY) - toJSON(f).encodeToByteArray() - } - DataEncoding.JBON, DataEncoding.JBON_GZIP -> { - val encoder = JbEncoder(dict) - encoder.buildFeatureFromMap(feature) - } - DataEncoding.JBON2, DataEncoding.JBON2_GZIP -> { - val encoder = JbEncoder2(dict) - encoder.buildTupleFromMap(feature) - } - else -> null - } - if (encoding.gzip && byteArray != null) byteArray = gzipDeflate(byteArray) - return byteArray - } - - /** - * Decode a Naksha feature, auto-detecting the encoding from header bytes. - * - * 1. gzip magic (`1F 8B`) → gunzip, then re-inspect. - * 2. JBON2 magic (`@JB\x02`) → JBON2 decoder. - * 3. First byte in `{`, `[`, ` `, `\t`, `\n`, `\r` → parse as JSON. - * 4. Otherwise → legacy JBON1 decoder. - * - * @param bytes the bytes to decode. - * @param dictReader the dictionary manager to use for legacy JBON1 decoding; if any. - * @return the Naksha feature, or _null_ if the bytes are empty / undecodable. - * @since 3.0 - */ - @JsStatic - @JvmStatic - fun decodeFeature(bytes: ByteArray?, dictReader: IDictReader?): NakshaFeature? { - if (bytes == null || bytes.isEmpty()) return null - val raw = if (isGzipped(bytes)) gzipInflate(bytes) else bytes - if (raw.isEmpty()) return null - return when { - isJbon2(raw) -> { - // The current JBON2 encoder embeds a local book and uses no global string-refs, - // so a global dictionary is not required to decode the feature object. - val decoder = JbDecoder2(null) - decoder.mapBytes(raw) - decoder.toAnyObject().proxy(NakshaFeature::class) - } - isJson(raw) -> { - val decoded = fromJSON(raw.decodeToString()) - if (decoded is PlatformMap) decoded.proxy(NakshaFeature::class) else null - } - else -> { - val decoder = JbFeatureDecoder(dictReader) - decoder.mapBytes(raw) - decoder.toAnyObject().proxy(NakshaFeature::class) - } - } - } - - private fun isGzipped(bytes: ByteArray): Boolean = - bytes.size >= 2 && bytes[0] == 0x1F.toByte() && bytes[1] == 0x8B.toByte() - - private fun isJbon2(bytes: ByteArray): Boolean = - bytes.size >= 4 && - bytes[0] == JB2_MAGIC[0] && bytes[1] == JB2_MAGIC[1] && - bytes[2] == JB2_MAGIC[2] && bytes[3] == JB2_MAGIC[3] - - private fun isJson(bytes: ByteArray): Boolean { - val b = bytes[0] - return b == 0x7B.toByte() || b == 0x5B.toByte() || - b == 0x20.toByte() || b == 0x09.toByte() || - b == 0x0A.toByte() || b == 0x0D.toByte() - } - /** * Decode Naksha tags from their binary representation. * @param bytes the bytes to decode. @@ -730,7 +425,7 @@ class Naksha private constructor() { } /** - * Encodes the given tag-list into the [set][naksha.model.objects.MemberType.SET] + * Encodes the given tag-list into the [tag_list][naksha.model.objects.MemberType.TAG_LIST] * representation: a JSON array, with the element order preserved. * @param tags the tags to encode. * @return the JSON array text representation, or _null_ if [tags] is _null_ / empty. @@ -747,9 +442,9 @@ class Naksha private constructor() { * Decodes Naksha tags from their JSON text representation into a [TagList]. * * Supports both persisted forms: - * - a JSON array ([set][naksha.model.objects.MemberType.SET], the default) is returned + * - a JSON array ([tag_list][naksha.model.objects.MemberType.TAG_LIST], the default) is returned * unmodified, preserving the element order; - * - a JSON object ([naksha.model.objects.MemberType.TAGS_FROM_ARRAY]) is re-flattened via + * - a JSON object ([naksha.model.objects.MemberType.TAG_MAP_FROM_ARRAY]) is re-flattened via * [TagMap.toTagList], in which case the original order is not guaranteed. * @param json the JSON text to decode (value of the `tags` member). * @return the decoded tag-list, or _null_ if [json] is _null_, blank, or neither an array nor an object. @@ -829,7 +524,7 @@ class Naksha private constructor() { @JvmStatic @JsStatic fun getStorage(storage: NakshaStorage): IStorage? { - val s = storagesByNumber[storage.number] ?: return null + val s = storagesByNumber[storage.databaseNumber] ?: return null val s2 = storagesById[storage.id] ?: return null return if (s!==s2 || s.config != storage) null else s } @@ -859,7 +554,7 @@ class Naksha private constructor() { */ @JvmStatic @JsStatic - fun getStorageByTupleNumber(tupleNumber: TupleNumber): IStorage? = storagesByNumber[tupleNumber.storageNumber] + fun getStorageByTupleNumber(tupleNumber: TupleNumber): IStorage? = storagesByNumber[tupleNumber.databaseNumber] /** * Set up the storage with the given configuration, enforces an [initStorage][AbstractStorage.initStorage] invocation that is forced to `create` or `upgrade` the storage. @@ -896,16 +591,16 @@ class Naksha private constructor() { fun useStorage(config: NakshaStorage): IStorage = _useStorage(config, null) private fun _useStorage(config: NakshaStorage, forceCreateOrUpgrade: Boolean?): IStorage { - var s = storagesByNumber[config.number] + var s = storagesByNumber[config.databaseNumber] var s2 = storagesById[config.id] if (s !== s2) { lock.acquire().use { - s = storagesByNumber[config.number] + s = storagesByNumber[config.databaseNumber] s2 = storagesById[config.id] if (s !== s2) { throw NakshaException( ILLEGAL_ARGUMENT, - "The storage-id (${config.id}) and -number (${config.number}) belong to different storages") + "The storage-id (${config.id}) and -number (${config.databaseNumber}) belong to different storages") } } } @@ -916,12 +611,12 @@ class Naksha private constructor() { return localS } lock.acquire().use { - var storage = storagesByNumber[config.number] + var storage = storagesByNumber[config.databaseNumber] val storage2 = storagesById[config.id] if (storage !== storage2) { throw NakshaException( ILLEGAL_ARGUMENT, - "The storage-id (${config.id}) and -number (${config.number}) belong to different storages") + "The storage-id (${config.id}) and -number (${config.databaseNumber}) belong to different storages") } if (storage != null) { if (storage.config.configEquals(config)) { @@ -933,7 +628,7 @@ class Naksha private constructor() { storage = Platform.newInstanceOf(klass) storage.invokeInitStorage(config, create = forceCreateOrUpgrade, upgrade = forceCreateOrUpgrade) storagesById[config.id] = storage - storagesByNumber[config.number] = storage + storagesByNumber[config.databaseNumber] = storage return storage } } @@ -973,19 +668,19 @@ class Naksha private constructor() { @JvmStatic @JsStatic fun removeStorage(config: NakshaStorage): IStorage? { - val s = storagesByNumber[config.number] + val s = storagesByNumber[config.databaseNumber] if (s == null || s.config != config) return null lock.acquire().use { - val storage = storagesByNumber[config.number] + val storage = storagesByNumber[config.databaseNumber] if (storage == null || storage.config != config) return null val storage2 = storagesById[config.id] if (storage !== storage2) { throw NakshaException( ILLEGAL_ARGUMENT, - "The storage-id (${config.id}) and -number (${config.number}) belong to different storages") + "The storage-id (${config.id}) and -number (${config.databaseNumber}) belong to different storages") } storagesById.remove(config.id) - storagesByNumber.remove(config.number) + storagesByNumber.remove(config.databaseNumber) storage.invokeShutdownStorage(true) return storage } @@ -1049,4 +744,4 @@ class Naksha private constructor() { _adminOptions.set(value) } } -} \ No newline at end of file +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaError.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaError.kt index a06ecc2baa..ce04c94e83 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaError.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaError.kt @@ -40,6 +40,14 @@ open class NakshaError() : AnyObject() { */ const val EXCEPTION = "Exception" + /** + * Thrown when a state is found that must not be found, for example some other part of the code should have prevented this state at this point. + * + * This results in a 500 Internal Server Error. + * @since 3.0.0 + */ + const val INTERNAL_ERROR = "InternalError" + /** * Returned when an already initialized storage is initialized, providing a wrong _storage-id_ and/or _storage-number_. * @since 3.0.0 @@ -100,7 +108,7 @@ open class NakshaError() : AnyObject() { * A not further specified conflict occurred when performing an operation, for example when the database reports a unique index violation, and the storage is not able to give a more specific reason. * @since 3.0.0 * @see [isConflict] - * @see [MAP_EXISTS] + * @see [CATALOG_EXISTS] * @see [MAP_NOT_FOUND] * @see [COLLECTION_EXISTS] * @see [COLLECTION_NOT_FOUND] @@ -208,7 +216,7 @@ open class NakshaError() : AnyObject() { * * @since 3.0.0 */ - const val MAP_EXISTS = "MapExists" + const val CATALOG_EXISTS = "MapExists" /** * A map does not exist, but is expected to exist. @@ -273,7 +281,7 @@ open class NakshaError() : AnyObject() { * @since 3.0 */ fun isConflict(): Boolean = when(code) { - MAP_EXISTS, + CATALOG_EXISTS, MAP_NOT_FOUND, COLLECTION_EXISTS, COLLECTION_NOT_FOUND, diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaException.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaException.kt index 504b5b7c17..ec2e19f846 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaException.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaException.kt @@ -12,7 +12,7 @@ import naksha.model.NakshaError.NakshaErrorCompanion.FORBIDDEN import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ID import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE -import naksha.model.NakshaError.NakshaErrorCompanion.MAP_EXISTS +import naksha.model.NakshaError.NakshaErrorCompanion.CATALOG_EXISTS import naksha.model.NakshaError.NakshaErrorCompanion.MAP_NOT_FOUND import naksha.model.NakshaError.NakshaErrorCompanion.UNSUPPORTED_OPERATION @@ -110,12 +110,12 @@ fun generalException(msg: String, cause: Throwable? = null): NakshaException = N fun mapNotFound(msg: String): NakshaException = NakshaException(MAP_NOT_FOUND, msg) /** - * Create [MAP_EXISTS] exception. + * Create [CATALOG_EXISTS] exception. * @param msg the message. * @return the [NakshaException]. * @since 3.0 */ -fun mapExists(msg: String): NakshaException = NakshaException(MAP_EXISTS, msg) +fun mapExists(msg: String): NakshaException = NakshaException(CATALOG_EXISTS, msg) /** * Create [COLLECTION_NOT_FOUND] exception. diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaIdType.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaIdType.kt new file mode 100644 index 0000000000..e3187c0ea6 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/NakshaIdType.kt @@ -0,0 +1,213 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model + +import naksha.model.Naksha.NakshaCompanion.MAX_ID_LENGTH +import naksha.model.Naksha.NakshaCompanion.MAX_INTERNAL_ID_LENGTH +import kotlin.js.JsExport +import kotlin.jvm.JvmOverloads + +/** + * All possible identifier types in Naksha with their validation rules. + * @property start the characters allowed as first character. + * @property chars the characters allowed at the rest of the identifier. + * @property rangeInfo informational string for error messages which characters are allowed. + */ +@JsExport +enum class NakshaIdType( + internal val start: Map, + internal val chars: Map, + internal val maxLength: Int, + internal val rangeInfo: String, + internal val isInternal: Boolean = false, +) { + /** + * The identifiers for `NakshaDatabase` _(not yet implemented)_. + * @since 3.0 + */ + DATABASE(_start(), _container(), MAX_ID_LENGTH, "[a-z][a-z0-9_-:]{${MAX_ID_LENGTH-1}}"), + + /** + * The identifiers for internal `NakshaDatabase` _(not yet implemented)_. + * @since 3.0 + */ + INTERNAL_DATABASE(_start(true), _container(true), MAX_INTERNAL_ID_LENGTH, "[a-z][a-z0-9_-:$~]{${MAX_INTERNAL_ID_LENGTH-1}}", true), + + /** + * The identifiers for [NakshaCatalog][naksha.model.objects.NakshaCatalog]. + * @since 3.0 + */ + CATALOG(_start(), _container(), MAX_ID_LENGTH, "[a-z][a-z0-9_-:]{${MAX_ID_LENGTH}}"), + + /** + * The identifiers for internal [NakshaCatalog][naksha.model.objects.NakshaCatalog]. + * @since 3.0 + */ + INTERNAL_CATALOG(_start(true), _container(true), MAX_INTERNAL_ID_LENGTH, "[a-z][a-z0-9_-:$~]{${MAX_INTERNAL_ID_LENGTH-1}}", true), + + /** + * The identifiers for [NakshaCollection][naksha.model.objects.NakshaCollection]. + * @since 3.0 + */ + COLLECTION(_start(), _container(), MAX_ID_LENGTH, "[a-z][a-z0-9_-:]{${MAX_ID_LENGTH-1}}"), + + /** + * The identifiers for internal [NakshaCollection][naksha.model.objects.NakshaCollection]. + * @since 3.0 + */ + INTERNAL_COLLECTION(_start(true), _container(true), MAX_INTERNAL_ID_LENGTH, "[a-z][a-z0-9_-:$~]{${MAX_INTERNAL_ID_LENGTH-1}}", true), + + /** + * The identifiers for [Member][naksha.model.objects.Member]. + * @since 3.0 + */ + MEMBER(_start(), _member(), MAX_ID_LENGTH, "[a-z][a-z0-9_]{${MAX_ID_LENGTH-1}}"), + + /** + * The identifiers for internal [Member][naksha.model.objects.Member]. + * @since 3.0 + */ + INTERNAL_MEMBER(_start(true), _member(true), MAX_ID_LENGTH, "[a-z_][a-z0-9_]{${MAX_ID_LENGTH-1}}", true), + + /** + * The identifiers for [Index][naksha.model.objects.Index]. + * @since 3.0 + */ + INDEX(_start(), _member(), MAX_ID_LENGTH, "[a-z][a-z0-9_]{${MAX_ID_LENGTH-1}}"), + + /** + * The identifiers for internal [Index][naksha.model.objects.Index]. + * @since 3.0 + */ + INTERNAL_INDEX(_start(true), _member(true), MAX_ID_LENGTH, "[a-z_][a-z0-9_]{${MAX_ID_LENGTH-1}}", true), + + /** + * The identifiers for `Book` _(not yet implemented)_. + * @since 3.0 + */ + BOOK(_start(), _member(), MAX_ID_LENGTH, "[a-z][a-z0-9_]{${MAX_ID_LENGTH-1}}"), + + /** + * The identifiers for internal `Book` _(not yet implemented)_. + * @since 3.0 + */ + INTERNAL_BOOK(_start(true), _member(true), MAX_INTERNAL_ID_LENGTH, "[a-z_][a-z0-9_]{${MAX_INTERNAL_ID_LENGTH-1}}"), + + /** + * The identifiers for `Book` _(not yet implemented)_. + * @since 3.0 + */ + TRANSACTION(_start_tx(), _tx(), 16, "[1-9][0-9]{15}"), + + /** + * The identifiers for [NakshaFeature][naksha.model.objects.NakshaFeature], actually without limits. + * @since 3.0 + */ + FEATURE(mapOf(), mapOf(), Int.MAX_VALUE, ".*"); + + /** + * Tests if the given **id** is a valid identifier of this kind. + * + * - `DATABASE` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `CATALOG` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `COLLECTION` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `MEMBER` - `[a-z][a-z0-9_]{Naksha.MAX_ID_LENGTH}` + * - `BOOK` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `TRANSACTION` - `[1-9][0-9]{15}` + * - `FEATURE` - no limit + * + * @return the given identifier, if it is valid; otherwise throws an exception. + * @throws NakshaException with [ILLEGAL_ID][naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ID], when `throwOnError` is _true_ and the identifier is not valid for the selected purpose (`idType`). + * @since 3.0 + * @see [isValidId] + */ + fun verify(id: String?): String { + isValidId(id, true) + return id!! + } + + /** + * Tests if the given **id** is a valid identifier of this kind. + * + * - `DATABASE` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `CATALOG` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `COLLECTION` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `MEMBER` - `[a-z][a-z0-9_]{Naksha.MAX_ID_LENGTH}` + * - `BOOK` - `[a-z][a-z0-9_:-]{Naksha.MAX_ID_LENGTH}` + * - `TRANSACTION` - `[1-9][0-9]{15}` + * - `FEATURE` - no limit + * + * **Beware**: Identifiers must not contain upper-case letters, because many storages does not make a difference between upper- and lower-cased letters. + * @param id the identifier to test. + * @param throwOnError if an exception should be thrown, when the verification failed. + * @return _true_ if the identifier is valid; _false_ otherwise. + * @throws NakshaException with [ILLEGAL_ID][naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ID], when `throwOnError` is _true_ and the identifier is not valid for the selected purpose (`idType`). + * @since 3.0 + * @see [verify] + */ + @JvmOverloads + fun isValidId(id: String?, throwOnError: Boolean = false): Boolean { + if (id.isNullOrEmpty()) { + if (throwOnError) throw illegalId("The given identifier is null or empty") + else return false + } + if (id == "naksha") { + if (isInternal) return true + if (throwOnError) throw illegalId("The identifier 'naksha' is forbidden") + else return false + } + if (id.length > maxLength) { + if (throwOnError) throw illegalId("The identifier '$id' is too long: ${id.length}, must be maximal $maxLength") + else return false + } + var i = 0 + var c = id[i++] + if (!start.containsKey(c)) { + if (throwOnError) throw illegalId("The first character must be $rangeInfo, but was $c") + else return false + } + while (i < id.length) { + c = id[i++] + if (!chars.containsKey(c)) { + if (throwOnError) throw illegalId("Invalid character at index $i: '$c', expected $rangeInfo") + else return false + } + } + return true + } +} + +private fun _start_tx(): Map { + val map = mutableMapOf() + for (c in '1' .. '9') map[c] = true + return map.toMap() +} +private fun _tx(): Map { + val map = mutableMapOf() + for (c in '0' .. '9') map[c] = true + return map.toMap() +} +private fun _start(internal: Boolean = false): Map { + val map = mutableMapOf() + for (c in 'a' .. 'z') map[c] = true + if (internal) map['_'] = true + return map.toMap() +} +private fun _member(internal: Boolean = false): Map { + val map = mutableMapOf() + for (c in 'a' .. 'z') map[c] = true + map['_'] = true + return map.toMap() +} +private fun _container(internal: Boolean = false): Map { + val map = mutableMapOf() + for (c in 'a' .. 'z') map[c] = true + map['_'] = true + map['-'] = true + map[':'] = true + if (internal) { + map['~'] = true + map['$'] = true + } + return map.toMap() +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/PgTx.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/PgTx.kt new file mode 100644 index 0000000000..84f1f99623 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/PgTx.kt @@ -0,0 +1,104 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model + +import naksha.base.Int64 +import naksha.jbon.IDictReader +import naksha.model.objects.* +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * A wrapper for an ongoing Naksha transaction, used by [write session's][IWriteSession]. + * + * This is a rather low level class, that normally applications should not care about, and which is not exposed by the session. It implements the _standard_ way of [Tuple] encoding and decoding. It helps storage implementors, and test implementations to generate [Tuple] without a storage. + * + * This class works without any real [storage][IStorage], however, storages can extend this class, and override certain behaviors with own better implementations, adjusted to the storage. + * + * @since 3.0 + */ +@JsExport +open class PgTx private constructor( + /** + * The storage instance for which this transaction is done. Does not have to be supplied. + * @since 3.0 + * @see [IStorage] + */ + val storage: IStorage? = null, + + /** + * The storage-number of the storage for which this transaction is done. + * @since 3.0 + * @see [NakshaStorage.number] + */ + val storageNumber: Int64, + + /** + * The unique version of the transaction. This value **should be** unique to this transaction. + * @since 3.0 + * @see [Version.auto] + * @see [Version.manual] + * @see [Version.now] + */ + val version: Version, + + /** + * The application-id of the application performing the modifications. + * @since 3.0 + */ + val appId: String, + + /** + * The author _(user)_ that performs the modifications; if any. + * + * If `null`, then the change is done by an application and the author and authorTs fields in [Tuple.membersBook] are not modified _(they stay what they are right now)_. + * @since 3.0 + */ + val author: String?, + + /** + * The dictionary reader to be used to encode and decode features. + * @since 3.0 + */ + val dictReader: IDictReader?, + + /** + * The session to which this transaction is attached. + * @since 3.0 + */ + val session: IWriteSession, +) { + + @JsName("storageTxWithStorageNumber") + constructor( + storageNumber: Int64, + version: Version, + appId: String, + author: String?, + dictReader: IDictReader?, + session: IWriteSession, + ): this(null, storageNumber, version, appId, author, dictReader, session) + + @JsName("storageTxWithStorage") + constructor( + storage: IStorage, + version: Version, + appId: String, + author: String?, + dictReader: IDictReader?, + session: IWriteSession, + ): this(storage, storage.number, version, appId, author, dictReader, session) + + /** + * The statistical transaction information, updated while this class is being used, should eventually be writted into the transaction-log of the storage. + * @since 3.0 + */ + open val nakshaTx: NakshaTx = NakshaTx().setVersion(version) + + /** + * The `updated_at` value being used for all [Tuple] created, basically just reads `transaction.time`. + * @since 3.0 + */ + open val updatedAt: Int64 + get() = nakshaTx.time +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/StorageTx.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/StorageTx.kt deleted file mode 100644 index ad11c93548..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/StorageTx.kt +++ /dev/null @@ -1,276 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model - -import naksha.base.Int64 -import naksha.jbon.IBook -import naksha.jbon.IDictReader -import naksha.jbon.HeapBook -import naksha.model.Metadata.Metadata_C.calculateHash -import naksha.model.Metadata.Metadata_C.calculateHereTile -import naksha.model.objects.* -import kotlin.js.JsExport -import kotlin.js.JsName - -/** - * A wrapper for an ongoing Naksha transaction, used by [write session's][IWriteSession]. - * - * This is a rather low level class, that normally applications should not care about, and which is not exposed by the session. It implements the _standard_ way of [Tuple] encoding and decoding. It helps storage implementors, and test implementations to generate [Tuple] without a storage. - * - * This class works without any real [storage][IStorage], however, storages can extend this class, and override certain behaviors with own better implementations, adjusted to the storage. - * - * @since 3.0 - * @see [Naksha.decodeTuple] - */ -@JsExport -open class StorageTx private constructor( - /** - * The storage instance for which this transaction is done. Does not have to be supplied. - * @since 3.0 - * @see [IStorage] - */ - val storage: IStorage? = null, - - /** - * The storage-number of the storage for which this transaction is done. - * @since 3.0 - * @see [NakshaStorage.number] - */ - val storageNumber: Int64, - - /** - * The unique version of the transaction. This value **should be** unique to this transaction. - * @since 3.0 - * @see [Version.of] - * @see [Version.now] - */ - val version: Version, - - /** - * The application-id of the application performing the modifications. - * @since 3.0 - */ - val appId: String, - - /** - * The author _(user)_ that performs the modifications; if any. - * - * If `null`, then the change is done by an application and the author and authorTs fields in [Tuple.members] are not modified _(they stay what they are right now)_. - * @since 3.0 - */ - val author: String?, - - /** - * The dictionary reader to be used to encode and decode features. - * @since 3.0 - */ - val dictReader: IDictReader?, -) { - - @JsName("storageTxWithStorageNumber") - constructor( - storageNumber: Int64, - version: Version, - appId: String, - author: String?, - dictReader: IDictReader?, - ): this(null, storageNumber, version, appId, author, dictReader) - - @JsName("storageTxWithStorage") - constructor( - storage: IStorage, - version: Version, - appId: String, - author: String?, - dictReader: IDictReader?, - ): this(storage, storage.number, version, appId, author, dictReader) - - /** - * The statistical transaction information, updated while this class is being used, should eventually be writted into the transaction-log of the storage. - * @since 3.0 - */ - open val transaction: NakshaTx = NakshaTx().setVersion(version) - - /** - * The `updated_at` value being used for all [Tuple] created, basically just reads `transaction.time`. - * @since 3.0 - */ - open val updatedAt: Int64 - get() = transaction.time - - /** - * Method to create the new members dict for the given write action on a feature. - * - * - Throws [NakshaError.ILLEGAL_ARGUMENT], if the given arguments are not sufficient to generate the new metadata. - * @param map the map into which to persist the feature. - * @param collection the collection into which to persist the feature. - * @param feature the new _(modified)_ state of the feature, for which the metadata should be created. - * @param action the [action][Action] being performed. - * @param atomic whether the write should be performed atomically. - * @return the new [IBook] with metadata members that is correct for the new state, based upon the given data. - * @since 3.0.0 - * @see [StorageTx] - */ - protected open fun buildMembers( - map: NakshaMap, - collection: NakshaCollection, - feature: NakshaFeature, - action: Action, - atomic: Boolean = false - ): IBook { - val dataEncoding = getDataEncoding(feature, collection) - val xyz = feature.properties.xyz - val isExistingFeature = !(action == Action.CREATED || (action == Action.UPDATED && !atomic)) - if (isExistingFeature && xyz.guid == null) { - throw illegalArg("$action with atomic=$atomic requires that the feature has a UUID!") - } - val updatedAt: Int64 = this.updatedAt - val createdAt: Int64? = if (isExistingFeature) xyz.createdAt else null - val author: String? - val authorTs: Int64? - if (xyz.author == null || xyz.author != this.author) { - author = this.author - authorTs = null - } else { - author = xyz.author - authorTs = xyz.authorTs - } - val featureType = if (collection.defaultFeatureType == feature.featureType) null else feature.featureType - val members = HeapBook() - members.put("updated_at", updatedAt) - members.put("created_at", createdAt) - members.put("author_ts", authorTs) - members.put("author", author) - members.put("app_id", appId) - members.put("data_encoding", dataEncoding.toString()) - members.put("cc", xyz.changeCount + 1) - members.put("hash", calculateHash(feature)) - members.put("here_tile", calculateHereTile(feature)) - members.put("id", feature.id) - members.put("origin", null) - members.put("target", null) - members.put("ft", featureType) - members.put("cv0", xyz.cv0) - members.put("cv1", xyz.cv1) - members.put("cv2", xyz.cv2) - members.put("cv3", xyz.cv3) - members.put("cs0", xyz.cs0) - members.put("cs1", xyz.cs1) - members.put("cs2", xyz.cs2) - members.put("cs3", xyz.cs3) - return members - } - - /** - * Builds the [Tuple] for the given write action. - * @param map the map in which the feature is being persisted. - * @param collection the collection in which the feature is being persisted. - * @param feature the feature. - * @param action the [action][Action] being performed. - * @param attachment the attachment. - * @param atomic whether the write should be performed atomically. - * @return the encoded [Tuple]. - */ - private fun buildTuple( - map: NakshaMap, - collection: NakshaCollection, - feature: NakshaFeature, - action: Action, - attachment: ByteArray?, - atomic: Boolean = false - ): Tuple { - val members = buildMembers(map, collection, feature, action, atomic) - val dataEncoding = getDataEncoding(feature, collection) - val xyz = feature.properties.xyz - val actionBits = Int64(action.intValue.toLong()) - val featureNumber = feature.featureNumber - val versionVal = version.txn and Int64(-4L) or actionBits - val nextVersion: Int64 = if (map.id == Naksha.ADMIN_MAP && collection.id == Naksha.TRANSACTIONS_COL) versionVal else Int64(-1L) - val dict = dictReader?.getEncodingDictionary(feature) - val featureBytes = Naksha.encodeFeature(feature, dataEncoding, dict) - val geoBytes = Naksha.encodeGeometry(feature.geometry) - val refPoint = Naksha.encodeGeometry(feature.referencePoint) - val tagsJson = Naksha.encodeTagList(xyz.tags) - if (members is naksha.jbon.HeapBook) { - members.put("geo", geoBytes) - members.put("ref_point", refPoint) - members.put("tags", tagsJson) - members.put("attachment", attachment) - } - return Tuple( - storageNumber = storageNumber, - mapNumber = map.number, - collectionNumber = collection.number, - featureNumber = featureNumber, - version = Version(versionVal), - nextVersion = nextVersion, - members = members, - feature = featureBytes - ) - } - - /** - * Convert the given [feature][NakshaFeature] into a [Tuple], when the feature was created. - * - * ### Note - * This method can be used for `upsert` as well, just that on-conflict the following values have to be updated from the already existing feature: - * - `created_at` - should be `created_at` of the existing version. - * - `cc` - _(change-count)_ should be set to the existing value + 1 - * - `author` - if the previous `author` is not the same as the current, then set to the current author _(author changed)_, otherwise set it to the previous one _(unchanged)_. Note: if either author is `null`, they are considered different. - * - `author_ts` - if the author changed, set to `null` _(same as `updatedAt`)_, otherwise set to the previous value. - * - `flags` - update `authorTs` flag accordingly; always set the `createdAt` flag; change the `action` to `UPDATED`. - * - * We need to beware, that in an `UPSERT` operation the [Tuple] changes, when we eventually perform the `UPDATE`, rather than the planned `INSERT`. So, we need to return the modified values in this case, therefore an `UPSERT` is a bit more complicated than an `INSERT` or `UPDATED`, but still we do not need to transfer all data forth and back, we can just create a new updated [Tuple]. - * - * **Therefore, do not store the [Tuple] in cache before being sure, that it really is persisted!** - * @param map the map in which the feature is going to be created. - * @param collection the collection in which the feature is going to be created. - * @param feature the feature that was created. - * @param attachment the attachment. - * @return the binary encoding of the [NakshaFeature] as [Tuple]. - */ - open fun created( - map: NakshaMap, - collection: NakshaCollection, - feature: NakshaFeature, - attachment: ByteArray? - ): Tuple = buildTuple(map, collection, feature, Action.CREATED, attachment) - - /** - * Convert the given [feature][NakshaFeature] into a [Tuple], when the feature was updated. - * @param map the map in which the feature is going to be created. - * @param collection the collection in which the feature is going to be created. - * @param feature the feature that was created. - * @param attachment the attachment. - * @param atomic whether the write should be performed atomically. - * @return the binary encoding of the [NakshaFeature] as [Tuple]. - */ - open fun updated( - map: NakshaMap, - collection: NakshaCollection, - feature: NakshaFeature, - attachment: ByteArray?, - atomic: Boolean = false, - ): Tuple = buildTuple(map, collection, feature, Action.UPDATED, attachment, atomic) - - /** - * Convert the given [feature][NakshaFeature] into a [Tuple], when the feature was deleted. - * - * ### Note - * There is no difference between purge and delete, except that on a purge, the [Tuple] is not persisted in shadow and/or history, which means that the [TupleNumber] of the purged feature potentially can't be load from storage or cache, so the [Tuple] is not available. - * @param map the map in which the feature is going to be created. - * @param collection the collection in which the feature is going to be created. - * @param feature the feature that was created. - * @param attachment the attachment. - * @return the binary encoding of the [NakshaFeature] as [Tuple]. - */ - open fun deleted( - map: NakshaMap, - collection: NakshaCollection, - feature: NakshaFeature, - attachment: ByteArray? - ): Tuple = buildTuple(map, collection, feature, Action.DELETED, attachment) - - private fun getDataEncoding(feature: NakshaFeature, collection: NakshaCollection): DataEncoding = - storage?.getDataEncoding(feature, collection) ?: Naksha.DEFAULT_DATA_ENCODING -} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt index 5a3117751e..335e1caac8 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt @@ -2,9 +2,7 @@ package naksha.model -import naksha.base.ListProxy -import naksha.base.NormalizerForm -import naksha.base.Platform +import naksha.base.StringList import naksha.model.TagNormalizer.TagNormalizer_C.normalizeTag import kotlin.js.JsExport import kotlin.js.JsName @@ -15,7 +13,7 @@ import kotlin.jvm.JvmStatic * A list of tags. */ @JsExport -open class TagList() : ListProxy(String::class) { +class TagList() : StringList() { /** * Create a tag list from the given arguments; the tags are normalized. diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt index fb0278836b..58f9e793ad 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt @@ -19,7 +19,7 @@ import kotlin.js.JsName * with use of [TagNormalizer] (that is used for example by [TagList]) */ @JsExport -open class TagMap() : MapProxy(String::class, Any::class) { +class TagMap() : MapProxy(String::class, Any::class) { @Suppress("LeakingThis") @JsName("of") diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tuple.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tuple.kt index 8c024a0bfe..87c061a3c8 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tuple.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tuple.kt @@ -2,74 +2,195 @@ package naksha.model +import naksha.base.AnyList +import naksha.base.AnyObject import naksha.base.Int64 +import naksha.base.ListProxy +import naksha.base.MapProxy +import naksha.base.Platform.PlatformCompanion.gzipDeflate +import naksha.base.Platform.PlatformCompanion.gzipInflate +import naksha.base.PlatformList +import naksha.base.PlatformMap +import naksha.base.PlatformMapApi.PlatformMapApiCompanion.map_get import naksha.base.WeakRef import naksha.jbon.IBook import naksha.model.objects.Member import naksha.geo.SpGeometry +import naksha.geo.SpType +import naksha.jbon.BookType +import naksha.jbon.HeapBook +import naksha.jbon.JB2_MAGIC +import naksha.jbon.JbDecoder2 +import naksha.jbon.JbEncoder2 +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.objects.MemberType +import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.objects.StandardMembers import kotlin.js.JsExport +import kotlin.js.JsStatic import kotlin.jvm.JvmField import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic /** - * A tuple represents a specific immutable state of a feature. - * - * The tuple stores the address fields ([storageNumber], [mapNumber], [collectionNumber], [featureNumber], [version]) as primary constructor parameters, with an optional [members] dict for values stored in dedicated member slots of the storage. - * + * A tuple represents a specific immutable state of a feature in binary encoding. * @since 3.0 */ @JsExport -data class Tuple( +data class Tuple @JvmOverloads constructor( /** - * The storage-number (database-level identifier). + * Feature serialized with the encoding described by the collection's dataEncoding. * @since 3.0 */ - @JvmField val storageNumber: Int64, + @JvmField val featureBytes: ByteArray, /** - * The map-number (catalog-level identifier). + * The members book provided by storage at read time. Contains dedicated member values, such as `id`, `tn`, etc. * @since 3.0 */ - @JvmField val mapNumber: Int, + @JvmField val membersBook: IBook, /** - * The collection-number. + * After encoding a [NakshaFeature] into a [Tuple] using [encodeFeature] method, the [previousTupleNumber] will be set by the encoder to the [TupleNumber] of the given feature; if it had any. + * + * This is metadata, it can as well be set manually, when a tuple is read from a storage. * @since 3.0 */ - @JvmField val collectionNumber: Int, + @JvmField var previousTupleNumber: TupleNumber? = null +) { - /** - * The feature-number. - * @since 3.0 - */ - @JvmField val featureNumber: Int64, + companion object Tuple_C { + /** + * Encodes the given [NakshaFeature] into JBON2 bytes and members book. + * + * @param feature the feature to encode. + * @param collection the collection for which to encode the feature; declares the members. + * @param action the action to apply. + * @param session the session for which to encode; declares the version. + * @param globalBook the global book to use for encoding; if any. + * @return the encoded feature bytes (JBON2, optionally GZIP-compressed). + * @since 3.0 + * @throws NakshaException if any fatal error happens when encoding. + */ + @JsStatic + @JvmStatic + fun encodeFeature( + feature: NakshaFeature, + collection: NakshaCollection, + action: Action, + session: IWriteSession, + globalBook: IBook? + ): Tuple { + val members = collection.useMembers() + val processors = session.processors + + // Update the tuple-number. + val tnMember = collection.useMember(StandardMembers.Tn) + val colTn = tnMember.getTupleNumber(collection) ?: + throw NakshaException(ILLEGAL_ARGUMENT, "The given collection does not have a valid tuple-number (uuid)") + if (colTn.featureNumber > Int.MAX_VALUE || colTn.featureNumber < Int.MIN_VALUE) { + throw NakshaException(ILLEGAL_ARGUMENT, "The given collection does have an invalid feature-number (uuid)") + } + // Read the current tuple-number of the feature; if any. + val prevTn: TupleNumber? = collection.useMember(StandardMembers.Tn).getTupleNumber(feature) + val newTn: TupleNumber + if (prevTn != null) { + if (action != Action.VERSION && action == Action.CREATE) { + throw NakshaException(ILLEGAL_ARGUMENT, "Invalid action CREATE given, the feature exists already (has a uuid)") + } + newTn = TupleNumber.copy(prevTn, session.useTransaction().version.number) + } else { + if (action != Action.VERSION && action != Action.CREATE) { + throw NakshaException(ILLEGAL_ARGUMENT, "Invalid action $action given, the feature does not exist (missing uuid)") + } + newTn = TupleNumber( + // The feature is stored in the same database as the collection it is inserted into. + colTn.databaseNumber, + // The feature is stored in the same catalog as the collection it is inserted into. + colTn.catalogNumber, + // The feature-number of the collection is the collection-number of the feature we want to store in the collection. + colTn.featureNumber.toInt(), + // The feature-number of the feature, either the `id` is a feature-number or it is calculated form hashing the `id`. + Naksha.featureNumber(feature.id), + // The version is the one of the transaction. + session.useTransaction().version.number + ) + } + // Update the feature with its new tuple-number. + tnMember.set(feature, newTn) + val globalBookTn: TupleNumber? + if (globalBook != null) { + if (newTn.databaseNumber != globalBook.databaseNumber || globalBook.featureNumber == null) { + throw NakshaException(ILLEGAL_ARGUMENT, "The given global book is not located in the same storage as the feature") + } + globalBookTn = TupleNumber.copy(newTn, storageNumber = globalBook.databaseNumber, featureNumber = globalBook.featureNumber) + } else { + globalBookTn = null + } - /** - * The version (transaction-number with action bits). - * @since 3.0 - */ - @JvmField val version: Version, + // Create the encoder with a custom member encoder. + val encoder = JbEncoder2(globalBook) + val membersBook = HeapBook(BookType.MEMBER_BOOK) + encoder.withMemberEncoder { path: Array, pathEnd: Int, value: Any? -> + // Build the current path key from the encoder's path. + members@ for (i in 0 until members.size) { + val member = members[i] ?: continue + if (StandardMembers.GlobalBookFeatureNumber.isSameAs(member)) { + return@withMemberEncoder if (globalBookTn != null) membersBook.put(member.name, globalBookTn.featureNumber) else -1 + } + val memberName = member.name + val memberPath = member.path + if (memberPath.size != pathEnd) continue + for (pi in 0 until pathEnd) { + if (path[pi] != memberPath[pi]) continue@members + } + // Path matches + var v = value + val procs = processors[memberName] + if (procs != null) { + for (proc in procs) { + v = proc.processMember(session, collection, feature, member, v) + } + } + // Coerce the value to the expected type. + v = FeatureMemberValues.coerce(value, member.dataType, feature.id, memberName) + + // Store in membersBook. + return@withMemberEncoder membersBook.put(memberName, v) + } + -1 + } - /** - * The next-version at which this tuple was superseded. `NULL`-sentinel (`-1L`) indicates the tuple is the current (HEAD) state. - * @since 3.0 - */ - @JvmField var nextVersion: Int64 = Int64(-1L), + // Encode the feature. + val raw = encoder.buildTupleFromMap(feature) - /** - * Optional members dict provided by storage at read time. Contains metadata values such as `id`, `app_id`, `updated_at`, `data_encoding`, etc. - * @since 3.0 - */ - @JvmField val members: IBook? = null, + // Optionally GZIP. + if (raw.size >= 1000) { + val compressed = gzipDeflate(raw) + if (compressed.size < raw.size) { + return Tuple(compressed, membersBook, prevTn) + } + } + return Tuple(raw, membersBook, prevTn) + } - /** - * Feature serialized with the encoding described by the collection's dataEncoding. - * @since 3.0 - */ - @JvmField val feature: ByteArray? = null, -) { + private fun isGzipped(bytes: ByteArray): Boolean = + bytes.size >= 2 && bytes[0] == 0x1F.toByte() && bytes[1] == 0x8B.toByte() + + private fun isJbon2(bytes: ByteArray): Boolean = + bytes.size >= 4 && + bytes[0] == JB2_MAGIC[0] && bytes[1] == JB2_MAGIC[1] && + bytes[2] == JB2_MAGIC[2] && bytes[3] == JB2_MAGIC[3] + + private fun isJson(bytes: ByteArray): Boolean { + val b = bytes[0] + return b == 0x7B.toByte() || b == 0x5B.toByte() || + b == 0x20.toByte() || b == 0x09.toByte() || + b == 0x0A.toByte() || b == 0x0D.toByte() + } + } override fun equals(other: Any?): Boolean { if (this === other) return true @@ -94,84 +215,123 @@ data class Tuple( return ref } - private var _tupleNumber: TupleNumber? = null + /** + * The [TupleNumber] of the [Tuple]. + * @since 3.0 + */ + @JvmField + val tupleNumber: TupleNumber = membersBook[StandardMembers.Tn.name] as TupleNumber /** - * The [TupleNumber] of the [Tuple], lazily cached. + * The next-version at which this tuple was superseded. `NULL`-sentinel indicates the tuple is the current _([Version.HEAD])_ state. * @since 3.0 */ - val tupleNumber: TupleNumber - get() { - var tn = _tupleNumber - if (tn == null) { - tn = TupleNumber(storageNumber, mapNumber, collectionNumber, featureNumber, version) - _tupleNumber = tn + var nextVersion: Int64 + get() = membersBook[StandardMembers.NextVersion.name] as Int64 + set(version: Int64) { + val members = this.membersBook + if (members is HeapBook) { + members.put(StandardMembers.NextVersion.name, version) + } else { + throw NakshaException(ILLEGAL_STATE, "Members book is immutable, failed to set nextVersion") } - return tn } private var _nextTupleNumber: TupleNumber? = null /** - * The [TupleNumber] of the next version, if [nextVersion] is set (not `-1L`). - * Lazily cached. + * The [TupleNumber] of the next version.; `null` if [Version.HEAD]. * @since 3.0 */ val nextTupleNumber: TupleNumber? get() { - if (nextVersion == Int64(-1L)) return null - var tn = _nextTupleNumber - if (tn == null) { - tn = TupleNumber(storageNumber, mapNumber, collectionNumber, featureNumber, Version(nextVersion)) - _nextTupleNumber = tn + if (nextVersion >= Version.HEAD.number) return null + var nextTn = _nextTupleNumber + if (nextTn == null) { + nextTn = TupleNumber.copy(tupleNumber, version = nextVersion) + _nextTupleNumber = nextTn } - return tn + return nextTn } + private var _globalBookTn: TupleNumber? = null + /** - * The feature as deserialized [NakshaFeature]. Lazily cached. + * The [TupleNumber] of the global book needed to decode this [Tuple]. This [TupleNumber] can be used to load the book from the storage. If being `null`, then no global book is needed for decoding. * @since 3.0 */ - val nakshaFeature: NakshaFeature? - get() = _nakshaFeature - private var _nakshaFeature: NakshaFeature? = null + val globalBookTn: TupleNumber? + get() { + val globalBookTn = _globalBookTn + if (globalBookTn != null) return globalBookTn + val raw = getMember(StandardMembers.GlobalBookFeatureNumber) + if (raw is Int64 || raw is Long) { + TODO("Use the global book number as feature-number, combine with storage-number from tuple, and with admin-catalog, book-collection, version is always HEAD, books are immutable and can not be versioned") + } + _globalBookTn = globalBookTn + return globalBookTn + } private var _id: String? = null /** - * The feature identifier. - * If [featureNumber] is non-negative, returns the stringified feature-number. - * If [featureNumber] is negative, reads the custom identifier from [members]. - * Lazily cached. + * The custom feature identifier. + * - If the feature-number is non-negative, returns the stringified feature-number. + * - If the feature-number is negative, reads the custom identifier from [membersBook]. * @since 3.0 + * @throws NakshaException with error [ILLEGAL_STATE] if the feature number */ val id: String get() { - var result = _id - if (result == null) { - result = if (featureNumber >= 0) featureNumber.toString() else getStringMember(StandardMembers.Id) - ?: throw IllegalStateException("Missing 'id' member for tuple with negative feature-number: $tupleNumber") - _id = result + var id: String? = _id + if (id != null) return id + id = membersBook[StandardMembers.Id.name] as String? + if (id != null) { + _id = id + return id + } + val featureNumber = tupleNumber.featureNumber + id = if (featureNumber >= 0) featureNumber.toString() else { + throw NakshaException(ILLEGAL_STATE, "Missing 'id' member for tuple with negative feature-number: $tupleNumber") } - return result + _id = id + return id } /** - * Get a String member by name. - * Returns `null` if the member is missing, the value is `null`, or not a String. + * Tests if the `Tuple` contains the given member, optionally if it is of the desired type. + * @param member The member to query, only uses the [Member.name]. + * @param dataType The data-type to test for. + * @return `true` if the member exists and the value is of the correct type; `false` otherwise. * @since 3.0 */ - fun getStringMember(member: Member): String? = - members?.getByName(member.name)?.let { v -> if (v is String) v else null } + @JvmOverloads + fun hasMember(member: Member, dataType: MemberType? = null): Boolean { + val index = membersBook.indexOfName(member.name) + if (index < 0) return false + if (dataType == null) return true + val value = membersBook.get(index) + return dataType.isInstance(value) + } /** - * Get a long member by name with a default. - * Returns [alt] if the member is missing or not a long-compatible type. + * Get a String member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. + * @since 3.0 + */ + fun getString(member: Member): String? = membersBook[member.name] as? String + + /** + * Get a long member. + * @param member The member to query, only uses the [Member.name]. + * @param alt The alternative to return. + * @return the value from the [membersBook] book or [alt], if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ @JvmOverloads - fun getLongMember(member: Member, alt: Int64 = Int64(0L)): Int64 = - members?.getByName(member.name)?.let { v -> + fun getLong(member: Member, alt: Int64 = Int64(0L)): Int64 = + membersBook[member.name]?.let { v -> when (v) { is Int64 -> v is Long -> Int64(v) @@ -181,13 +341,15 @@ data class Tuple( } ?: alt /** - * Get an int member by name with a default. - * Returns [alt] if the member is missing or not an int-compatible type. + * Get an integer member. + * @param member The member to query, only uses the [Member.name]. + * @param alt The alternative to return. + * @return the value from the [membersBook] book or [alt], if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ @JvmOverloads - fun getIntMember(member: Member, alt: Int = 0): Int = - members?.getByName(member.name)?.let { v -> + fun getInt(member: Member, alt: Int = 0): Int = + membersBook[member.name]?.let { v -> when (v) { is Int -> v is Number -> v.toInt() @@ -196,13 +358,15 @@ data class Tuple( } ?: alt /** - * Get a double member by name with a default. - * Returns [alt] if the member is missing or not a double-compatible type. + * Get a double member. + * @param member The member to query, only uses the [Member.name]. + * @param alt The alternative to return. + * @return the value from the [membersBook] book or [alt], if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ @JvmOverloads - fun getDoubleMember(member: Member, alt: Double = Double.NaN): Double = - members?.getByName(member.name)?.let { v -> + fun getDouble(member: Member, alt: Double = Double.NaN): Double = + membersBook[member.name]?.let { v -> when (v) { is Double -> v is Number -> v.toDouble() @@ -211,91 +375,125 @@ data class Tuple( } ?: alt /** - * Get a boolean member by name with a default. - * Returns [alt] if the member is missing or not a boolean. + * Get a boolean member. + * @param member The member to query, only uses the [Member.name]. + * @param alt The alternative to return. + * @return the value from the [membersBook] book or [alt], if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ @JvmOverloads - fun getBooleanMember(member: Member, alt: Boolean = false): Boolean = - members?.getByName(member.name)?.let { v -> if (v is Boolean) v else alt } ?: alt + fun getBoolean(member: Member, alt: Boolean = false): Boolean = membersBook[member.name] as? Boolean ?: alt - /** - * Get the raw value of a member by name. - * Returns `null` if the member is missing or the value is `null`. + /** + * Get the raw value of the member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ - fun getMember(member: Member): Any? = members?.getByName(member.name) + fun getMember(member: Member): Any? { + return when (val raw = membersBook[member.name]) { + is String -> raw + is Int64 -> raw + is Byte -> Int64(raw.toInt()) + is Short -> Int64(raw.toInt()) + is Int -> Int64(raw) + is Long -> Int64(raw) + is Float -> raw.toDouble() + is Double -> raw + is ByteArray -> raw + is SpGeometry -> raw + is TagMap -> raw + is TagList -> raw + is ListProxy<*> -> raw + is PlatformList -> raw.proxy(AnyList::class) + is MapProxy<*, *> -> { + // Detect geometry. + // If noSpGeometry, we can't differ between a TagMap and a simple Object in raw JSON, so treat is as Object. + val type = SpType.ofDefined(raw["type"] as String?) + if (type != null) raw.proxy(type.klass) else raw + } + is PlatformMap -> { + // Detect geometry. + // If noSpGeometry, we can't differ between a TagMap and a simple Object in raw JSON, so treat is as Object. + val type = SpType.ofDefined(map_get(raw, "type") as String?) + if (type != null) raw.proxy(type.klass) else raw.proxy(AnyObject::class) + } + else -> null + } + } /** - * Get a ByteArray member by name. - * Returns `null` if the member is missing, the value is `null`, or not a ByteArray. + * Get the byte-array value of the member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ - fun getByteArray(member: Member): ByteArray? = - members?.getByName(member.name)?.let { v -> if (v is ByteArray) v else null } + fun getByteArray(member: Member): ByteArray? = membersBook[member.name] as ByteArray? /** - * Get a geometry member by name. - * Decodes the TWKB bytes into [SpGeometry]. - * Returns `null` if the member is missing, the value is `null`, or not a ByteArray. + * Get the [SpGeometry] value of the member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ - fun getSpatialMember(member: Member): SpGeometry? { - val bytes = members?.getByName(member.name) as? ByteArray ?: return null - return try { Naksha.decodeGeometry(bytes) } catch (_: Exception) { null } + fun getSpatial(member: Member): SpGeometry? { + val raw = membersBook[member.name] + if (raw is SpGeometry) return raw + if (raw is ByteArray) return try { Naksha.decodeGeometry(raw) } catch (_: Exception) { null } + if (raw is PlatformMap) return raw.proxy(SpGeometry::class) + return null } /** - * Get a tags member by name. - * Decodes the JSON text into a [TagMap]. - * Returns `null` if the member is missing, the value is `null`, or not a String. + * Get the [TagMap] value of the member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ fun getTags(member: Member): TagMap? { - val json = members?.getByName(member.name) as? String ?: return null - return try { Naksha.decodeTags(json) } catch (_: Exception) { null } + val raw = membersBook[member.name] + if (raw is TagMap) return raw + if (raw is PlatformMap) return raw.proxy(TagMap::class) + return null } /** - * Get a tags member by name as a [TagList]. - * Decodes the JSON text, supporting both persisted forms: a JSON array - * ([set][naksha.model.objects.MemberType.SET], the default — order preserved) and a JSON object - * ([naksha.model.objects.MemberType.TAGS_FROM_ARRAY] — re-flattened, order not guaranteed). - * Returns `null` if the member is missing, the value is `null`, or not a String. + * Get the [TagList] value of the member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ fun getTagList(member: Member): TagList? { - val json = members?.getByName(member.name) as? String ?: return null - return try { Naksha.decodeTagList(json) } catch (_: Exception) { null } + val raw = membersBook[member.name] + if (raw is TagList) return raw + if (raw is PlatformList) return raw.proxy(TagList::class) + return null } /** - * The [DataEncoding] of this tuple, read from [members]. - * Returns [Naksha.DEFAULT_DATA_ENCODING] if not set. + * Get the set value of the member. + * @param member The member to query, only uses the [Member.name]. + * @return the value from the [membersBook] book or `null`, if the member is missing, the value is `null`, or not the requested type. * @since 3.0 */ - val dataEncoding: DataEncoding - get() { - val str = members?.getByName("data_encoding") as? String ?: return Naksha.DEFAULT_DATA_ENCODING - return try { DataEncoding.fromString(str) } catch (_: Exception) { Naksha.DEFAULT_DATA_ENCODING } - } + fun getSet(member: Member): ListProxy<*>? { + val raw = membersBook[member.name] + if (raw is ListProxy<*>) return raw + if (raw is PlatformList) return raw.proxy(AnyList::class) + return null + } /** - * Convert this [Tuple] into a [NakshaFeature] by decoding the feature blob and building the XYZ namespace. - * @return the decoded [NakshaFeature], or `null` if the tuple has no feature data. + * Decode this [Tuple] into a [NakshaFeature]. + * @param globalBook The global book to use to decode the [Tuple]. Need to be loaded from the storage, if [globalBookTn] is not `null`. + * @return the decoded [NakshaFeature]. * @since 3.0 + * @throws NakshaException if any error occurs. */ - fun toNakshaFeature(): NakshaFeature? { - val feature = Naksha.decodeFeature(this.feature, null) ?: return null - feature.properties.xyz = XyzNs.fromTuple(this) - val tags = getTagList(StandardMembers.Tags) - if (tags != null) { - feature.properties.xyz.tags = tags - } - val geoBytes = getByteArray(StandardMembers.Geometry) - if (geoBytes != null) { - feature.geometry = Naksha.decodeGeometry(geoBytes) - } - return feature + fun decodeFeature(globalBook: IBook?): NakshaFeature { // TODO: Java: After switching back to Java, we can allow arbitrary return types. + val rawBytes = if (isGzipped(featureBytes)) gzipInflate(featureBytes) else featureBytes + val decoder = JbDecoder2(globalBook, membersBook) + decoder.mapBytes(rawBytes) + return decoder.toAnyObject().proxy(NakshaFeature::class) } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleCache.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleCache.kt index fbdb8aafac..2e8db727b1 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleCache.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleCache.kt @@ -198,7 +198,7 @@ class TupleCache internal constructor() { it != null && it.tuple == null && it.feature == null else it != null && it.tuple == null - }.filterNotNull().groupBy { it.tupleNumber.storageNumber } + }.filterNotNull().groupBy { it.tupleNumber.databaseNumber } for (entry in byStorage) { val storageNumber = entry.key val toLoad = entry.value @@ -317,7 +317,7 @@ class TupleCache internal constructor() { */ @JsName("getDictReaderForTuple") fun getDictReader(tuple: Tuple): IDictReader? - = getDictReader(tuple.tupleNumber.storageNumber) + = getDictReader(tuple.tupleNumber.databaseNumber) /** * A method to query for a dictionary reader. @@ -327,7 +327,7 @@ class TupleCache internal constructor() { @JsName("getDictReaderForFeatureTuple") fun getDictReader(featureTuple: FeatureTuple): IDictReader? { val tuple = featureTuple.tuple ?: return null - return getDictReader(tuple.tupleNumber.storageNumber) + return getDictReader(tuple.tupleNumber.databaseNumber) } /** diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumber.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumber.kt index 6bfa0e3c92..4fb4ef9cc7 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumber.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumber.kt @@ -22,31 +22,31 @@ import kotlin.jvm.JvmOverloads import kotlin.jvm.JvmStatic /** - * The in-memory representation of the unique [Tuple] identifier. + * The in-memory representation of the unique address of a [Tuple]. * - * The full qualified [Tuple] identifier is a 256-bit value _(32 byte)_, persisting out of the storage-number, map-number, collection-number, feature-number, and [transaction-number][Version]. Note that the lower two bits of `version.txn` encode the [action][Action]. + * The full qualified [Tuple] address is a 256-bit value _(32 byte)_, persisting out of the database-number, catalog-number, collection-number, feature-number, and version. Note that the lower two bits of version encode the [action][Action]. * - * The tuple-number is stringified into: + * The tuple-number is stringified either into URN: * ``` - * {storage-number}:{map-number}:{collection-number}:{feature-number}:{version} + * urn:naksha:tn:{database-number}:{catalog-number}:{collection-number}:{feature-number}:{version} * ``` * - * - There are no two [tuples][Tuple] with the same [tuple-number][TupleNumber]; world-wide. + * There are no two [tuple][Tuple] with the same [tuple-number][TupleNumber]; world-wide. * @since 3.0 */ @JsExport data class TupleNumber( /** - * The storage-number, uniquely identifies the storage where the tuple is stored. + * The database-number, uniquely identifies the storage where the tuple is stored. * @since 3.0 */ - @JvmField val storageNumber: Int64, + @JvmField val databaseNumber: Int64, /** - * The map-number of the map in which the tuple is stored within the storage. + * The catalog-number of the map in which the tuple is stored within the storage. * @since 3.0.0 */ - @JvmField val mapNumber: Int, + @JvmField val catalogNumber: Int, /** * The collection-number of the collection in which the tuple is stored within the storage. @@ -63,19 +63,12 @@ data class TupleNumber( /** * The version _(transaction)_ of which the [Tuple] is part of. - * The lower 2 bits of [Version.txn] encode the [Action]. + * The lower 2 bits of [Version.number] encode the [Action]. * @since 3.0 * @see [Version.HEAD] */ - @JvmField val version: Version, + @JvmField val version: Int64, ) : Comparable { - /** - * The transaction-number. - * @since 3.0 - */ - val txn: Int64 - get() = version.txn - /** * The partition-number of the [Tuple], a value between `0` and `65536` _(exclusive)_. * @since 3.0 @@ -85,29 +78,29 @@ data class TupleNumber( /** * The [Action] applied to generate the [Tuple] referred by this [TupleNumber]. - * Decoded from the lower 2 bits of [version.txn]. + * Decoded from the lower 2 bits of [version]. * @since 3.0 */ val action: Action - get() = Action.fromValue(version.txn.toInt() and 3) + get() = Action.fromValue(version.toInt() and 3) /** - * Calculates the partition-index where this [Tuple] will be located. + * Calculates the distribution partition-index where this [Tuple] will be located. * - * If the given partitions are less than `2`, the method always returns `0`, if the number is bigger than `65536` the result will be mapped back into the range between `0` and `65536` _(exclusive)_. + * If the given partitions are less than `2`, the method always returns `-1`. If the number is bigger than `65536` the result will be mapped back into the range between `0` and `65536` _(exclusive)_. * @param partitions the number of partitions - * @return the partition-index, a value between `0` and `partitions - 1`, maximal `65535` + * @return the partition-index, a value between `0` and `partitions - 1` _(maximal 65535)_; or `-1` if there are no distribution partitions. * @since 3.0 */ - fun partitionIndex(partitions: Int): Int = if (partitions <= 1) 0 else (partitionNumber % partitions) and 0xffff + fun partitionIndex(partitions: Int): Int = if (partitions < 2) -1 else (partitionNumber % partitions) and 0xffff override fun hashCode(): Int = version.hashCode() override fun compareTo(other: TupleNumber): Int { - var i64_diff = storageNumber - other.storageNumber + var i64_diff = databaseNumber - other.databaseNumber if (i64_diff < 0) return -1 if (i64_diff > 1) return 1 - var i32_diff = mapNumber - other.mapNumber + var i32_diff = catalogNumber - other.catalogNumber if (i32_diff < 0) return -1 if (i32_diff > 1) return 1 i32_diff = collectionNumber - other.collectionNumber @@ -128,11 +121,11 @@ data class TupleNumber( override fun equals(other: Any?): Boolean { if (this === other) return true return other is TupleNumber - && storageNumber == other.storageNumber - && mapNumber == other.mapNumber + && databaseNumber == other.databaseNumber + && catalogNumber == other.catalogNumber && collectionNumber == other.collectionNumber && featureNumber == other.featureNumber - && version.txn == other.version.txn + && version == other.version } private lateinit var _string: String @@ -147,7 +140,7 @@ data class TupleNumber( */ override fun toString(): String { if (!this::_string.isInitialized) { - _string = "$storageNumber:$mapNumber:$collectionNumber:$featureNumber:$version" + _string = "$databaseNumber:$catalogNumber:$collectionNumber:$featureNumber:$version" } return _string } @@ -168,7 +161,7 @@ data class TupleNumber( val fn = this.featureNumber if (fn >= 0) throw NakshaException(ILLEGAL_STATE, "The feature-number is not auto-generated, failed to calculate alternative") val new_fn = Naksha.alternativeInt64(fn) - return TupleNumber(storageNumber, mapNumber, collectionNumber, new_fn, version) + return TupleNumber(databaseNumber, catalogNumber, collectionNumber, new_fn, version) } private var _urn: String? = null @@ -258,11 +251,11 @@ data class TupleNumber( val view = Platform.newDataView(byteArray) var offset = 0 if (variant.encodeStorageNumber()) { - dataview_set_int64(view, offset, storageNumber) + dataview_set_int64(view, offset, databaseNumber) offset += 8 } if (variant.encodeMapNumber()) { - dataview_set_int32(view, offset, mapNumber) + dataview_set_int32(view, offset, catalogNumber) offset += 4 } if (variant.encodeCollectionNumber()) { @@ -273,7 +266,7 @@ data class TupleNumber( dataview_set_int64(view, offset, featureNumber) offset += 8 } - dataview_set_int64(view, offset, version.txn) + dataview_set_int64(view, offset, version) return byteArray } @@ -307,14 +300,14 @@ data class TupleNumber( @JvmOverloads fun copy( tn: TupleNumber, - version: Version? = null, + version: Int64? = null, featureNumber: Int64? = null, collectionNumber: Int? = null, mapNumber: Int? = null, storageNumber: Int64? = null, ) = TupleNumber( - storageNumber ?: tn.storageNumber, - mapNumber ?: tn.mapNumber, + storageNumber ?: tn.databaseNumber, + mapNumber ?: tn.catalogNumber, collectionNumber ?: tn.collectionNumber, featureNumber ?: tn.featureNumber, version ?: tn.version, @@ -326,7 +319,7 @@ data class TupleNumber( * This happens for various reasons, for example when a [Tuple] is created in the client at runtime, and not yet persisted in any storage, therefore does not yet have a valid tuple-number. * @since 3.0 */ - val HEAD = TupleNumber(Int64(0), 0, 0, Int64(0), Version.HEAD) + val HEAD = TupleNumber(Int64(0), 0, 0, Int64(0), Version.HEAD.number) /** * Restore a [TupleNumber] from a binary encoding. @@ -343,7 +336,7 @@ data class TupleNumber( offset: Int, variant: TupleNumberVariant, tn: TupleNumber - ): TupleNumber = fromBinary(Binary(bytes, offset), variant, tn.storageNumber, tn.mapNumber, tn.collectionNumber, tn.featureNumber) + ): TupleNumber = fromBinary(Binary(bytes, offset), variant, tn.databaseNumber, tn.catalogNumber, tn.collectionNumber, tn.featureNumber) fun fromB256(bytes: ByteArray) = fromByteArray(bytes, 0, B256) @@ -360,7 +353,7 @@ data class TupleNumber( * Restore a [TupleNumber] from a binary encoding. * @param bytes the binary to read. * @param offset the index of the first byte to read. - * @param variant the variant to read. + * @param variant the variant to read, if omitted, the value is auto-detected by the byte-array size. * @param storageNumber if the binary does not encode the storage-number _(anything other than [B256])_, so variant is [B64], [B128], [B160], or [B192]. * @param mapNumber if the binary does not encode the map-number, so variant is [B64], [B128], or [B160]. * @param collectionNumber if the binary does not encode the collection-number, so variant is [B64] or [B128]. @@ -371,8 +364,8 @@ data class TupleNumber( @JvmOverloads fun fromByteArray( bytes: ByteArray, - offset: Int, - variant: TupleNumberVariant, + offset: Int = 0, + variant: TupleNumberVariant = TupleNumberVariant.fromValue(bytes.size - offset), storageNumber: Int64? = null, mapNumber: Int? = null, collectionNumber: Int? = null, @@ -408,8 +401,8 @@ data class TupleNumber( ?: throw illegalArg("Missing collection-number for given tuple-number variant $variant") val f_num = if (variant.encodeFeatureNumber()) binary.readInt64() else featureNumber ?: throw illegalArg("Missing collection-number for given tuple-number variant $variant") - val txn = binary.readInt64() - return TupleNumber(storage_num, map_num, col_num, f_num, Version(txn)) + val version = binary.readInt64() + return TupleNumber(storage_num, map_num, col_num, f_num, version) } /** @@ -454,7 +447,7 @@ data class TupleNumber( * - `map-number` _(32-bit integer)_ * - `collection-number` _(32-bit integer)_ * - `feature-number` _(64-bit integer)_ - * - `version` _(64-bit integer, the raw [Version.txn] value)_ + * - `version` _(64-bit integer, the raw [Version.number] value)_ * @param parts the string parts of the tuple-number. * @param offset the index in the given list where the `storage-number` is located, defaults to `0`. * @return the deserialized [TupleNumber]. @@ -470,7 +463,7 @@ data class TupleNumber( val mapNumber = parts[offset + MAP_NUMBER].toInt(10) val colNumber = parts[offset + COLLECTION_NUMBER].toInt(10) val featureNumber = Int64(parts[offset + FEATURE_NUMBER].toLong(10)) - val version = Version.fromString(parts[offset + VERSION]) + val version = Version.fromString(parts[offset + VERSION]).number return TupleNumber(storageNumber, mapNumber, colNumber, featureNumber, version) } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberBinaryArray.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberBinaryArray.kt index 53a564d6e2..d7f72a00dd 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberBinaryArray.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberBinaryArray.kt @@ -214,7 +214,7 @@ data class TupleNumberBinaryArray( val collectionNumber = sharedCollectionNumber ?: dataview_get_int32(view, offset + collectionNumberOffset) val featureNumber = sharedFeatureNumber ?: dataview_get_int64(view, offset + featureNumberOffset) val txn = dataview_get_int64(view, offset + txnOffset) - val tupleNumber = TupleNumber(storageNumber, mapNumber, collectionNumber, featureNumber, Version(txn)) + val tupleNumber = TupleNumber(storageNumber, mapNumber, collectionNumber, featureNumber, txn) if (!disableCache) { var cache = tupleNumberCache if (index <= cache.size) { // Note: This only happens, when being EMPTY @@ -385,11 +385,11 @@ data class TupleNumberBinaryArray( override fun lastIndexOf(element: TupleNumber?): Int { if (element == null) return -1 for (i in size - 1 downTo 0) { - if (element.storageNumber == getStorageNumber(i) - && element.mapNumber == getMapNumber(i) + if (element.databaseNumber == getStorageNumber(i) + && element.catalogNumber == getMapNumber(i) && element.collectionNumber == getCollectionNumber(i) && element.featureNumber == getFeatureNumber(i) - && element.version.txn == getTxn(i)) return i + && element.version == getTxn(i)) return i } return -1 } @@ -397,11 +397,11 @@ data class TupleNumberBinaryArray( override fun indexOf(element: TupleNumber?): Int { if (element == null) return -1 for (i in 0 until size) { - if (element.storageNumber == getStorageNumber(i) - && element.mapNumber == getMapNumber(i) + if (element.databaseNumber == getStorageNumber(i) + && element.catalogNumber == getMapNumber(i) && element.collectionNumber == getCollectionNumber(i) && element.featureNumber == getFeatureNumber(i) - && element.version.txn == getTxn(i)) return i + && element.version == getTxn(i)) return i } return -1 } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberList.kt index 9836b90a1c..9881cf304c 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberList.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberList.kt @@ -9,7 +9,6 @@ import naksha.base.PlatformDataViewApi.PlatformDataViewApiCompanion.dataview_set import naksha.base.PlatformDataViewApi.PlatformDataViewApiCompanion.dataview_set_int64 import naksha.model.BinaryUtil.BinaryUtil_C.TYPE_TUPLE_NUMBER_ARRAY import naksha.model.BinaryUtil.BinaryUtil_C.writeSimpleHeader -import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B128 import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B160 import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B192 @@ -78,13 +77,13 @@ class TupleNumberList : ListProxy(TupleNumber::class) { if (variant == null) { // We found a first tuple, we hope that each tuple can be encoded in 64-bit only. variant = B64 - storageNumber = tupleNumber.storageNumber - mapNumber = tupleNumber.mapNumber + storageNumber = tupleNumber.databaseNumber + mapNumber = tupleNumber.catalogNumber collectionNumber = tupleNumber.collectionNumber featureNumber = tupleNumber.featureNumber continue } - if (storageNumber != tupleNumber.storageNumber) { + if (storageNumber != tupleNumber.databaseNumber) { // We need to encode all values individually variant = B256 storageNumber = null @@ -94,7 +93,7 @@ class TupleNumberList : ListProxy(TupleNumber::class) { break } if (variant === B192) continue - if (mapNumber != tupleNumber.mapNumber) { + if (mapNumber != tupleNumber.catalogNumber) { // We need to encode individual map-, collection-, and feature-numbers variant = B192 mapNumber = null @@ -153,11 +152,11 @@ class TupleNumberList : ListProxy(TupleNumber::class) { for (tupleNumber in this) { if (tupleNumber == null) continue if (variant.encodeStorageNumber()) { - dataview_set_int64(view, i, tupleNumber.storageNumber) + dataview_set_int64(view, i, tupleNumber.databaseNumber) i += 8 } if (variant.encodeMapNumber()) { - dataview_set_int32(view, i, tupleNumber.mapNumber) + dataview_set_int32(view, i, tupleNumber.catalogNumber) i += 4 } if (variant.encodeCollectionNumber()) { @@ -168,7 +167,7 @@ class TupleNumberList : ListProxy(TupleNumber::class) { dataview_set_int64(view, i, tupleNumber.featureNumber) i += 8 } - dataview_set_int64(view, i, tupleNumber.txn) + dataview_set_int64(view, i, tupleNumber.version) i += 8 } check(i == SIZE) diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberVariant.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberVariant.kt index c5e1a5ee38..fac5db4714 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberVariant.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TupleNumberVariant.kt @@ -188,14 +188,7 @@ class TupleNumberVariant internal constructor() : JsEnum() { private set /** - * The number of bytes that are shared for tuple-number's of this variant _(optional header size)_. - * - * The total header size is actually `8` byte, plus this value, meaning: - * - variant `0` encodes all values for each tuple-number, therefore no header place needed. - * - variant `1` encodes `storage-number` in the header, so `8` byte needed. - * - variant `2` encodes `storage-number`, and `map-number` in the header, so `12` byte needed. - * - variant `3` encodes `storage-number`, `map-number`, and `collection-number` in the header, so `16` byte needed. - * - variant `4` encodes `storage-number`, `map-number`, `collection-number`, and `feature-number` in the header, so `24` byte needed. + * The number of bytes that are shared in the header of a tuple-number-array for tuple-number's of this variant. * @since 3.0 * @see [BinaryUtil.writeSimpleHeader] * @see [sharedStorageNumber] diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Version.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Version.kt index fcf789d69c..d1480ac587 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Version.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Version.kt @@ -4,6 +4,7 @@ package naksha.model import naksha.base.Int64 import naksha.base.Timestamp +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT import kotlin.js.JsExport import kotlin.js.JsName import kotlin.js.JsStatic @@ -11,6 +12,7 @@ import kotlin.jvm.JvmField import kotlin.jvm.JvmOverloads import kotlin.jvm.JvmStatic +// TODO: @AI: Fix the documentation, it does not match the actual one. /** * Wrapper for a version (transaction number), encoded as an unsigned 56-bit integer (the upper 8 bits are always zero). * @@ -41,37 +43,52 @@ import kotlin.jvm.JvmStatic * * ### String representation * - * [toString] returns the raw [txn] value as a plain decimal number, regardless of whether the + * [toString] returns the raw [number] value as a plain decimal number, regardless of whether the * version is dated or manual. [fromString] accepts both the decimal form and the legacy * `{year}:{month}:{day}:{seqWithAction}` form for backward-compatibility. * - * @property txn the raw 64-bit transaction number (upper 8 bits always zero). + * @property number the raw 53-bit version number (upper 11 bits are always zero). * @since 3.0 */ @JsExport -open class Version(@JvmField val txn: Int64) : Comparable { +open class Version(@JvmField val number: Int64) : Comparable { + // TODO: When we move nack to Java, we can extend Number, so that we're basically like a Long. + init { + if ((number and HEAD.number) != number) { + throw NakshaException(ILLEGAL_ARGUMENT, "$number is not a valid version") + } + } /** - * Convert a transaction number given as [Long] into a version. - * @param txn the transaction number. + * Convert a [Long] into a [Int64] version. + * @param value the transaction number. * @since 3.0 */ @Suppress("NON_EXPORTABLE_TYPE") @JsName("fromLong") - constructor(txn: Long) : this(Int64(txn)) + constructor(value: Long) : this(Int64(value)) + + /** + * Convert a stringified version. + * @param value the stringified version as decimal number. + * @since 3.0 + * @throws NumberFormatException if the given string is no valid version. + */ + @JsName("fromString") + constructor(value: String) : this(Int64(value.toLong())) companion object VersionCompanion { /** Maximum year value (15-bit, JS-safe upper bound). */ private const val YEAR_MAX = 32767 /** Minimum year for a dated version. */ - private const val YEAR_DATED_MIN = 16 + private const val YEAR_MIN = 16 /** Mask for the 30-bit sequence field. */ private val SEQ_30_MASK = Int64(0x3FFF_FFFF) /** Mask for the 41-bit manual-version seq field (upper 21 bits of the 64-bit value must be 0). */ - private val MANUAL_SEQ_MASK = Int64(0x1FF_FFFF_FFFF) // 41 bits + private val MANUAL_SEQ_MASK = Int64(0x1FF_FFFF_FFFF) // 41 bits; 2,199,023,255,551 /** * Create a version from a double (JavaScript number). @@ -86,7 +103,7 @@ open class Version(@JvmField val txn: Int64) : Comparable { * Creates a version from its string representation. * * Accepts either: - * - A pure decimal encoding of the 64-bit [txn] value. + * - A pure decimal encoding of the 64-bit [number] value. * - The human-readable form `{year}:{month}:{day}:{seq}` (seq is the 30-bit sequence, no action bits). * * Throws [NakshaError.ILLEGAL_ARGUMENT] if the string is invalid. @@ -97,21 +114,9 @@ open class Version(@JvmField val txn: Int64) : Comparable { @JvmStatic fun fromString(s: String): Version { try { - if (s.indexOf(':') >= 0) { - val parts = s.split(':') - if (parts.size != 4) throw Exception("Expected 4 colon-separated parts") - // The 4th field carries the raw lower 32 bits of the txn (including action bits in 1-0). - val year = parts[0].toInt() - val month = parts[1].toInt() - val day = parts[2].toInt() - val seqRaw = Int64(parts[3].toLong()) // raw lower 32 bits, includes action in bits 1-0 - val txn = (Int64(year) shl 41) or (Int64(month) shl 37) or (Int64(day) shl 32) or seqRaw - return Version(txn) - } else { - return Version(Int64(s.toLong())) - } - } catch (e: Exception) { - throw NakshaException(NakshaError.ILLEGAL_ARGUMENT, "Invalid version string: $s") + return Version(Int64(s.toLong())) + } catch (_: Exception) { + throw NakshaException(ILLEGAL_ARGUMENT, "Invalid version string: $s") } } @@ -125,15 +130,14 @@ open class Version(@JvmField val txn: Int64) : Comparable { * @param month month of the year; must be in 1..12. * @param day day of the month; must be in 1..31. * @param seq 30-bit sequence number within the day; must be in 0..1073741823 (0x3FFF_FFFF). - * @param action the [Action] to encode in the lower 2 bits; defaults to [Action.CREATED]. + * @param action the [Action] to encode in the lower 2 bits. * @since 3.0 */ @JvmStatic @JsStatic - @JvmOverloads - fun auto(year: Int, month: Int, day: Int, seq: Int64, action: Action = Action.CREATED): Version { - require(year in YEAR_DATED_MIN..YEAR_MAX) { - "year must be in $YEAR_DATED_MIN..$YEAR_MAX, got $year" + fun auto(year: Int, month: Int, day: Int, seq: Int64, action: Action): Version { + require(year in YEAR_MIN..YEAR_MAX) { + "year must be in $YEAR_MIN..$YEAR_MAX, got $year" } require(month in 1..12) { "month must be in 1..12, got $month" } require(day in 1..31) { "day must be in 1..31, got $day" } @@ -151,20 +155,19 @@ open class Version(@JvmField val txn: Int64) : Comparable { /** * Constructs a **manual** version. * - * The resulting [txn] must have its upper 21 bits (63–43) all zero, which means the effective + * The resulting [seq] must have its upper 21 bits (63–43) all zero, which means the effective * value fits in 43 bits. The [seq] therefore must be in 0..0x1FF_FFFF_FFFF (41 bits), since * the lower 2 bits are reserved for [action]. * * Throws [IllegalArgumentException] if [seq] is out of range. * * @param seq 41-bit sequence value; must be in 0..0x1FF_FFFF_FFFF. - * @param action the [Action] to encode in the lower 2 bits; defaults to [Action.CREATED]. + * @param action the [Action] to encode in the lower 2 bits. * @since 3.0 */ @JvmStatic @JsStatic - @JvmOverloads - fun manual(seq: Int64, action: Action = Action.CREATED): Version { + fun manual(seq: Int64, action: Action): Version { require(seq >= Int64(0) && seq <= MANUAL_SEQ_MASK) { "seq for a manual version must be in 0..${MANUAL_SEQ_MASK.toLong()} (41-bit), got $seq" } @@ -175,27 +178,46 @@ open class Version(@JvmField val txn: Int64) : Comparable { * Creates a dated version for the current wall-clock time. * * @param seq 30-bit sequence number within the current day; must be in 0..1073741823. - * @param action the [Action] to encode; defaults to [Action.CREATED]. + * @param action the [Action] to encode. * @since 3.0 */ @JvmStatic @JsStatic - @JvmOverloads - fun now(seq: Int64, action: Action = Action.CREATED): Version { + fun now(seq: Int64, action: Action): Version { val now = Timestamp.now() return auto(now.year, now.month, now.day, seq, action) } /** - * The _HEAD_ sentinel version (`txn == 0`). + * Turns the given version into a real version, so setting the lower two bit to two, and ensure that the value is a valid version number. + * + * @param version the version to turn into a version. + * @return the given version with the lowest two bit set. + * @throws NakshaException with error [ILLEGAL_ARGUMENT], if the given version is no valid version. + * @since 3.0 + */ + @JvmStatic + @JsStatic + @JvmOverloads + fun asVersion(version: Int64): Int64 { + val v = version or Int64(3) + if (v > HEAD.number) return HEAD.number + if (v < 0) throw illegalArg("Versions must not be negative") + return v + } + + /** + * The _HEAD_ sentinel version _(`9_007_199_254_740_991` aka `2^53-1`)_. Can be used as well to mask version to ensure valid version number, like `version & Version.HEAD`. * - * When a [Tuple] is the current HEAD state its `nextVersion` is synthesised as this value - * (the column is not physically stored in HEAD tables). + * When a [Tuple] is the _HEAD_ state its next-version is synthesized as this value or as `null`, which has by definition the same meaning. * @since 3.0 */ @JvmField @JsStatic - val HEAD = Version(0L) + val HEAD = Version(9_007_199_254_740_991L) + // = 2^53-1, aka Number.MAX_SAFE_INTEGER + // 3n + (1073741823n << 2n) + (31n << 32n) + (15n << (32n+5n)) + (4095n << (32n+5n+4n)) = 9007199254740991n + // bitwise: 0x001f_ffff_ffff_ffff /** * The minimum valid dated version (year=16, month=1, day=1, seq=0, action=CREATED). @@ -203,7 +225,51 @@ open class Version(@JvmField val txn: Int64) : Comparable { */ @JvmField @JsStatic - val MIN = auto(16, 1, 1, Int64(0)) + val MIN_AUTO = auto(16, 1, 1, Int64(0), Action.CREATE) + // 0n + (0n << 2n) + (1n << 32n) + (1n << (32n+5n)) + (16n << (32n+5n+4n)) = 35326106009600n + // bitwise: 0x0000_2021_0000_0000 + + /** + * The maximum valid dated version (year=4095, month=12, day=31, seq=1,073,741,823, action=VERSION). + * @since 3.0 + */ + @JvmField + @JsStatic + val MAX_AUTO = auto(4095, 12, 31, Int64(1_073_741_823), Action.VERSION) + // 3n + (1073741823n << 2n) + (31n << 32n) + (12n << (32n+5n)) + (4095n << (32n+5n+4n)) = 9006786937880575n + // bitwise: 0x001f_ff9f_ffff_ffff + + /** + * The minimum manual version (year=0, month=0, day=0, seq=1, action=CREATED). + * @since 3.0 + */ + @JvmField + @JsStatic + val MIN_MANUAL = manual(Int64(1), Action.CREATE) + + /** + * The maximum valid manual version (seq=2,199,023,255,551, action=VERSION). + * @since 3.0 + */ + @JvmField + @JsStatic + val MAX_MANUAL = manual(MANUAL_SEQ_MASK, Action.CREATE) + + /** + * The absolute minimum version number _(3)_. + * @since 3.0 + */ + @JvmField + @JsStatic + val MIN = MIN_MANUAL + + /** + * The absolute maximal valid version number _(9,007,199,254,740,988)_. This is three less than [HEAD]. + * @since 3.0 + */ + @JvmField + @JsStatic + val MAX = Version(9_007_199_254_740_988L) /** * The minimum value of the 30-bit sequence field (zero). @@ -215,7 +281,7 @@ open class Version(@JvmField val txn: Int64) : Comparable { /** * The maximum value of the 30-bit sequence field (`0x3FFF_FFFF` = 1073741823). - * Also usable as a bitmask to extract the sequence from a shifted value. + * Also, usable as a bitmask to extract the sequence from a shifted value. * @since 3.0 */ @JvmField @@ -223,13 +289,13 @@ open class Version(@JvmField val txn: Int64) : Comparable { val SEQ_MAX: Int64 = SEQ_30_MASK /** - * The raw increment to add to [txn] to advance the sequence counter by one while keeping the + * The raw increment to add to [number] to advance the sequence counter by one while keeping the * action bits unchanged. Equal to `1 shl 2` = `4`. * @since 3.0 */ @JvmField @JsStatic - val SEQ_END: Int64 = Int64(1) shl 2 + val SEQ_INC: Int64 = Int64(1) shl 2 } private var _year = -1 @@ -241,7 +307,7 @@ open class Version(@JvmField val txn: Int64) : Comparable { */ val year: Int get() { - if (_year < 0) _year = (txn ushr 41).toInt() + if (_year < 0) _year = (number ushr 41).toInt() return _year } @@ -253,7 +319,7 @@ open class Version(@JvmField val txn: Int64) : Comparable { */ val month: Int get() { - if (_month < 0) _month = (txn ushr 37).toInt() and 0xF + if (_month < 0) _month = (number ushr 37).toInt() and 0xF return _month } @@ -265,25 +331,29 @@ open class Version(@JvmField val txn: Int64) : Comparable { */ val day: Int get() { - if (_day < 0) _day = (txn ushr 32).toInt() and 0x1F + if (_day < 0) _day = (number ushr 32).toInt() and 0x1F return _day } private var _seq: Int64? = null /** - * The 30-bit sequence number (`(txn ushr 2) and 0x3FFF_FFFF`). + * The 30-bit or 41-bit sequence number. * - * For dated versions this is the sequence within the day (0–1073741823). - * For manual versions this is the upper 30 bits of the 41-bit seq value passed to [manual]. + * - For dated versions this is the sequence within the day _(0..1,073,741,823)_. + * - For manual versions this is the version-number shifted right by 2 _(1..2,199,023,255,551)_. * @since 3.0 */ val seq: Int64 get() { var s = _seq if (s == null) { - s = (txn ushr 2) and SEQ_MAX - _seq = s + if (isDated()) { + s = (number ushr 2) and SEQ_MAX + _seq = s + } else { + s = (number ushr 2) and MANUAL_SEQ_MASK + } } return s } @@ -292,7 +362,7 @@ open class Version(@JvmField val txn: Int64) : Comparable { * Returns `true` if this is a **dated** version, i.e. the year field (`txn ushr 41`) is ≥ 16. * @since 3.0 */ - fun isDated(): Boolean = (txn ushr 41).toInt() >= 16 + fun isDated(): Boolean = (number ushr 41).toInt() >= 16 /** * Returns `true` if this is a **manual** version, i.e. the year field is < 16 and the upper 21 bits are zero. @@ -302,28 +372,28 @@ open class Version(@JvmField val txn: Int64) : Comparable { fun isManualVersion(): Boolean = !isDated() /** - * Returns the [Action] encoded in the lower 2 bits of [txn]. + * Returns the [Action] encoded in the lower 2 bits of [number]. * @since 3.0 */ - fun action(): Action = Action.fromValue(txn.toInt() and 3) + fun action(): Action = Action.fromValue(number.toInt() and 3) private var _string: String? = null override fun equals(other: Any?): Boolean { - if (other is Int64) return txn eq other - if (other is Version) return txn eq other.txn + if (other is Int64) return number eq other + if (other is Version) return number eq other.number return false } override fun compareTo(other: Version): Int { - val diff = txn.minus(other.txn) + val diff = number.minus(other.number) return if (diff.eq(0)) 0 else if (diff < 0) -1 else 1 } - override fun hashCode(): Int = txn.hashCode() + override fun hashCode(): Int = number.hashCode() /** - * Returns the version as a plain decimal string of the raw [txn] value. + * Returns the version as a plain decimal string of the raw [number] value. * * This representation is lossless for all version types (dated and manual) and survives * a round-trip through [fromString]. The legacy `{year}:{month}:{day}:{seqWithAction}` @@ -333,7 +403,7 @@ open class Version(@JvmField val txn: Int64) : Comparable { override fun toString(): String { var s = _string if (s == null) { - s = txn.toLong().toString() + s = number.toLong().toString() _string = s } return s diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt index d9c14a3adb..c64fe7721f 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt @@ -3,7 +3,24 @@ package naksha.model import naksha.base.* -import naksha.model.objects.StandardMembers +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzAppId +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzAuthor +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzAuthorTimestamp +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzChangeCount +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCreatedAt +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomString0 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomString1 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomString2 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomString3 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomValue0 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomValue1 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomValue2 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzCustomValue3 +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzHash +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzHereTile +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzOrigin +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzTarget +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzUpdatedAt import kotlin.DeprecationLevel.WARNING import kotlin.js.JsExport import kotlin.js.JsStatic @@ -19,8 +36,8 @@ import kotlin.jvm.JvmStatic * - **Create**: Clients should create features without an XYZ namespace, except for the [tags]. * - **Delete**: The content of the feature is ignored for deletes. * - **Update**: If the client wants to update a feature, it should read the feature, then modify it, and then send the modified feature back, without changing the XYZ namespace, except for the [tags]. When it does operate like this, the change is performed atomically safe, because the [uuid] will hint the server which version was modified by the client, and is expected to be current _HEAD_. If the feature was updated meanwhile by another client, the server can try to perform an auto-merge, otherwise it will respond with a conflict (which is what the low-level storage will do). - * - **Fork**: If the client reads a feature, and then writes it into another storage, map, or collection, or when the client modifies the ID of the feature, and then sends the feature to a service, without modifying the XYZ namespace, the storage will be able to detect that this is a **fork**. Forking means, that a feature is moved between storages, maps, or collections, or is re-identified. The storage will turn the action into [CREATED][Action.CREATED], and copy the [uuid] (which refers to the modified foreign state) into the [origin]. When the feature, that was forked, is modified later, it is possible to find all forks in all storages, maps, and collections, and to update them doing a [three-way-merge](https://en.wikipedia.org/wiki/Merge_(version_control)#Three-way_merge). This process is called rebase. - * - **Split**: If the client need to split a feature into parts, for example a Topology into two, it is required that it clones the original feature, and then modifies the copies, while deleting the original feature that was split. All features being part of the split will have the same [uuid]. The feature that was split is expected to be deleted with [action] set to [DELETED][Action.DELETED], and the new parts are created with [action] set to [CREATED][Action.CREATED]. + * - **Fork**: If the client reads a feature, and then writes it into another storage, map, or collection, or when the client modifies the ID of the feature, and then sends the feature to a service, without modifying the XYZ namespace, the storage will be able to detect that this is a **fork**. Forking means, that a feature is moved between storages, maps, or collections, or is re-identified. The storage will turn the action into [CREATED][Action.CREATE], and copy the [uuid] (which refers to the modified foreign state) into the [origin]. When the feature, that was forked, is modified later, it is possible to find all forks in all storages, maps, and collections, and to update them doing a [three-way-merge](https://en.wikipedia.org/wiki/Merge_(version_control)#Three-way_merge). This process is called rebase. + * - **Split**: If the client need to split a feature into parts, for example a Topology into two, it is required that it clones the original feature, and then modifies the copies, while deleting the original feature that was split. All features being part of the split will have the same [uuid]. The feature that was split is expected to be deleted with [action] set to [DELETED][Action.DELETE], and the new parts are created with [action] set to [CREATED][Action.CREATE]. * - **Join**: If the client need to join multiple features into a single one, it is required to create a new (_merged_) feature, and to delete all features joined into this new one. It is important that the client set the [target] of all features being part of the join to the [_HEAD_ Guid][Guid.headOf] of the _created_ (_new_) feature. The [_HEAD_ Guid][Guid.headOf] is simply the [Guid] without the [tuple-number][TupleNumber], so basically `urn:here:naksha:guid:{feature-id}`. * @since 3.0 */ @@ -175,7 +192,7 @@ class XyzNs : AnyObject() { */ const val CS3 = "cs3" - private val _ACTION = NotNullEnum(Action::class) { _, _ -> Action.CREATED } + private val _ACTION = NotNullEnum(Action::class) { _, _ -> Action.CREATE } private val _DATA_ENCODING_NULL = NullableEnum(DataEncoding::class) private val _APP_ID = NotNullProperty(String::class) { _, _ -> NakshaContext.appId() } private val _STRING_NULL = NullableProperty(String::class, autoRemove = true) @@ -196,108 +213,48 @@ class XyzNs : AnyObject() { @JsStatic fun fromTuple(tuple: Tuple): XyzNs { val tn = tuple.tupleNumber - val members = tuple.members - val id = members?.getByName("id") as? String ?: tuple.featureNumber.toString() - val guid = Guid(id, tn) - val updatedAt = tuple.getLongMember(StandardMembers.UpdatedAt) - val createdAt = tuple.getLongMember(StandardMembers.CreatedAt).let { - if (it == Int64(0L)) updatedAt else it - } - val authorTs = tuple.getLongMember(StandardMembers.AuthorTimestamp)?.let { - if (it == Int64(0)) updatedAt else it - } ?: updatedAt - val nextVersion = tuple.nextVersion - val nextTn = if (nextVersion != Int64(-1L)) TupleNumber( - tn.storageNumber, tn.mapNumber, tn.collectionNumber, tn.featureNumber, Version(nextVersion) - ) else null - val base_tn = members?.getByName("base_tn")?.let { - if (it is ByteArray) TupleNumber.fromByteArray(it, 0, TupleNumberVariant.TupleNumberVariant_C.B128, - tn.storageNumber, tn.mapNumber, tn.collectionNumber) - else null - } + val id = tuple.id + val guid = Guid.fromTuple(tuple) + val updatedAt = tuple.getLong(XyzUpdatedAt, Platform.currentMillis()) + val createdAt = tuple.getLong(XyzCreatedAt, updatedAt) + val authorTs = tuple.getLong(XyzAuthorTimestamp,updatedAt) + val nextTn = tuple.nextTupleNumber return AnyObject().apply { setRaw(UUID, guid.toString()) if (nextTn != null) setRaw(NUUID, Guid(id, nextTn).toString()) - if (base_tn != null) setRaw(MUUID, Guid(id, base_tn).toString()) + setRaw(UPDATED_AT, updatedAt) if (createdAt != updatedAt) setRaw(CREATED_AT, createdAt) if (authorTs != updatedAt) setRaw(AUTHOR_TS, authorTs) - setRaw(UPDATED_AT, updatedAt) - setRaw(CHANGE_COUNT, tuple.getIntMember(StandardMembers.ChangeCount)) - setRaw(APP_ID, tuple.getStringMember(StandardMembers.AppId)) - val author = tuple.getStringMember(StandardMembers.Author) + setRaw(CHANGE_COUNT, tuple.getInt(XyzChangeCount, 1)) + setRaw(APP_ID, tuple.getString(XyzAppId)) + val author = tuple.getString(XyzAuthor) if (author != null) setRaw(AUTHOR, author) - setRaw(DATA_ENCODING, tuple.getStringMember(StandardMembers.DataEncoding)) setRaw(ACTION, tn.action.toString()) - setRaw(HASH, tuple.getIntMember(StandardMembers.Hash)) - setRaw(HERE_TILE, tuple.getIntMember(StandardMembers.HereTile)) - val origin = tuple.getStringMember(StandardMembers.Origin) + setRaw(HASH, tuple.getInt(XyzHash)) + setRaw(HERE_TILE, tuple.getInt(XyzHereTile)) + val origin = tuple.getString(XyzOrigin) if (origin != null) setRaw(ORIGIN, origin) - val target = tuple.getStringMember(StandardMembers.Target) + val target = tuple.getString(XyzTarget) if (target != null) setRaw(TARGET, target) - val cv0 = members?.getByName("cv0") - if (cv0 != null) setRaw(CV0, cv0 as? Double) - val cv1 = members?.getByName("cv1") - if (cv1 != null) setRaw(CV1, cv1 as? Double) - val cv2 = members?.getByName("cv2") - if (cv2 != null) setRaw(CV2, cv2 as? Double) - val cv3 = members?.getByName("cv3") - if (cv3 != null) setRaw(CV3, cv3 as? Double) - val cs0 = tuple.getStringMember(StandardMembers.CustomString0) + val cv0: Double = tuple.getDouble(XyzCustomValue0) + if (cv0 == cv0) setRaw(CV0, cv0) + val cv1: Double = tuple.getDouble(XyzCustomValue1) + if (cv1 == cv1) setRaw(CV1, cv1) + val cv2: Double = tuple.getDouble(XyzCustomValue2) + if (cv2 == cv2) setRaw(CV2, cv2) + val cv3: Double = tuple.getDouble(XyzCustomValue3) + if (cv3 == cv3) setRaw(CV3, cv3) + val cs0 = tuple.getString(XyzCustomString0) if (cs0 != null) setRaw(CS0, cs0) - val cs1 = tuple.getStringMember(StandardMembers.CustomString1) + val cs1 = tuple.getString(XyzCustomString1) if (cs1 != null) setRaw(CS1, cs1) - val cs2 = tuple.getStringMember(StandardMembers.CustomString2) + val cs2 = tuple.getString(XyzCustomString2) if (cs2 != null) setRaw(CS2, cs2) - val cs3 = tuple.getStringMember(StandardMembers.CustomString3) + val cs3 = tuple.getString(XyzCustomString3) if (cs3 != null) setRaw(CS3, cs3) }.proxy(XyzNs::class) } - /** - * Create the XYZ-namespace from the given [Metadata]. - * @param meta the [Metadata] - * @return the [XYZ namespace][XyzNs]. - * @see [Metadata.fromXyzNs] - * @deprecated Use [fromTuple] instead. - */ - @Deprecated("Use fromTuple instead", ReplaceWith("fromTuple(tuple)")) - @JvmStatic - @JsStatic - fun fromMetadata(meta: Metadata): XyzNs { - val tn = meta.tupleNumber - val guid = Guid(meta.id, tn) - val nextVersion = meta.nextVersion - val nextTn = if (nextVersion != null) TupleNumber( - tn.storageNumber, tn.mapNumber, tn.collectionNumber, tn.featureNumber, Version(nextVersion) - ) else null - val base_tn = meta.baseTupleNumber - return AnyObject().apply { - setRaw(UUID, guid.toString()) - if (nextTn != null) setRaw(NUUID, Guid(meta.id, nextTn).toString()) - if (base_tn != null) setRaw(MUUID, Guid(meta.id, base_tn).toString()) - if (meta.createdAt != meta.updatedAt) setRaw(CREATED_AT, meta.createdAt) - if (meta.authorTs != meta.updatedAt) setRaw(AUTHOR_TS, meta.authorTs) - setRaw(UPDATED_AT, meta.updatedAt) - setRaw(CHANGE_COUNT, meta.changeCount) - setRaw(APP_ID, meta.appId) - if (meta.author != null) setRaw(AUTHOR, meta.author) - setRaw(DATA_ENCODING, meta.dataEncoding.toString()) - setRaw(ACTION, meta.action().toString()) - setRaw(HASH, meta.hash) - setRaw(HERE_TILE, meta.hereTile) - if (meta.origin != null) setRaw(ORIGIN, meta.origin) - if (meta.target != null) setRaw(TARGET, meta.target) - if (meta.cv0 != null) setRaw(CV0, meta.cv0) - if (meta.cv1 != null) setRaw(CV1, meta.cv1) - if (meta.cv2 != null) setRaw(CV2, meta.cv2) - if (meta.cv3 != null) setRaw(CV3, meta.cv3) - if (meta.cs0 != null) setRaw(CS0, meta.cs0) - if (meta.cs1 != null) setRaw(CS1, meta.cs1) - if (meta.cs2 != null) setRaw(CS2, meta.cs2) - if (meta.cs3 != null) setRaw(CS3, meta.cs3) - }.proxy(XyzNs::class) - } - /** * A method to normalize a list of tags. * @@ -558,8 +515,9 @@ class XyzNs : AnyObject() { get() { // Downward compatibility hack. val raw = getRaw("version") - if (raw is Int64 && raw >= Version.MIN) return Version(raw) - return guid?.tupleNumber?.version + if (raw is Int64 && raw >= Version.MIN_AUTO) return Version(raw) + val version = guid?.tupleNumber?.version + return if (version != null) Version(version) else null } /** @@ -567,7 +525,7 @@ class XyzNs : AnyObject() { * @since 2.0 */ val txn: Int64? - get() = guid?.tupleNumber?.txn + get() = guid?.tupleNumber?.version /** * The action of the [Tuple], encoded as the lower 2 bits of the transaction number. diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/BoolMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/BoolMember.kt new file mode 100644 index 0000000000..2d176ea25e --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/BoolMember.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.BOOLEAN +import kotlin.js.JsName + +class BoolMember() : TypedMember() { + override fun verify(): BoolMember { + if (dataType != BOOLEAN) { + throw illegalState("The member was illegally cast, expected subtype: $BOOLEAN, found: $dataType") + } + return this + } + + /** Creates a new boolean member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = BOOLEAN + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a boolean member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != BOOLEAN) throw illegalArg("The given member is not of boolean type") + this.name = member.name + this.dataType = BOOLEAN + this.path = path?.validate() ?: member.path + } + + /** Retrieves the boolean value of this member from the given feature. */ + fun get(feature: NakshaFeature): Boolean? = getBoolean(feature) + + /** + * Retrieves the boolean value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Boolean? = getBoolean(tuple) + + /** Sets the boolean value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Boolean): Boolean? = setPath(feature, path, value) as Boolean? +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/ByteArrayMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/ByteArrayMember.kt new file mode 100644 index 0000000000..eacf9e78e7 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/ByteArrayMember.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.BYTE_ARRAY +import kotlin.js.JsName + +class ByteArrayMember() : TypedMember() { + override fun verify(): ByteArrayMember { + if (dataType != BYTE_ARRAY) { + throw illegalState("The member was illegally cast, expected subtype: $BYTE_ARRAY, found: $dataType") + } + return this + } + + /** Creates a new byte array member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = BYTE_ARRAY + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a byte array member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != BYTE_ARRAY) throw illegalArg("The given member is not of byte_array type") + this.name = member.name + this.dataType = BYTE_ARRAY + this.path = path?.validate() ?: member.path + } + + /** Retrieves the byte array value of this member from the given feature. */ + fun get(feature: NakshaFeature): ByteArray? = getByteArray(feature) + + /** + * Retrieves the byte array value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): ByteArray? = getByteArray(tuple) + + /** Sets the byte array value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: ByteArray): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Float32Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Float32Member.kt new file mode 100644 index 0000000000..d2a3a56582 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Float32Member.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.FLOAT32 +import kotlin.js.JsName + +class Float32Member() : TypedMember() { + override fun verify(): Float32Member { + if (dataType != FLOAT32) { + throw illegalState("The member was illegally cast, expected subtype: $FLOAT32, found: $dataType") + } + return this + } + + /** Creates a new float32 member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = FLOAT32 + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a float32 member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != FLOAT32) throw illegalArg("The given member is not of float32 type") + this.name = member.name + this.dataType = FLOAT32 + this.path = path?.validate() ?: member.path + } + + /** Retrieves the float32 value of this member from the given feature. */ + fun get(feature: NakshaFeature): Float? = getDouble(feature)?.toFloat() + + /** + * Retrieves the float32 value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Float? = getDouble(tuple)?.toFloat() + + /** Sets the float32 value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Float): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Float64Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Float64Member.kt new file mode 100644 index 0000000000..d92d8e5396 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Float64Member.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.FLOAT64 +import kotlin.js.JsName + +class Float64Member() : TypedMember() { + override fun verify(): Float64Member { + if (dataType != FLOAT64) { + throw illegalState("The member was illegally cast, expected subtype: $FLOAT64, found: $dataType") + } + return this + } + + /** Creates a new float64 member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = FLOAT64 + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a float64 member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != FLOAT64) throw illegalArg("The given member is not of float64 type") + this.name = member.name + this.dataType = FLOAT64 + this.path = path?.validate() ?: member.path + } + + /** Retrieves the float64 value of this member from the given feature. */ + fun get(feature: NakshaFeature): Double? = getDouble(feature) + + /** + * Retrieves the float64 value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Double? = getDouble(tuple) + + /** Sets the float64 value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Double): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Index.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Index.kt index d026c970a0..c9d5df02f9 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Index.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Index.kt @@ -19,8 +19,8 @@ import kotlin.js.JsName * collection. The [type] decides the storage method: * - [IndexType.BTREE]: ordered index for equality and range queries on scalar and text columns. * - [IndexType.SPATIAL]: spatial index covering a 2D-encoded geometry column. [on] must contain exactly one geometry member. - * - [IndexType.TAGS]: inverted index over a tags member ([MemberType.TAGS] or [MemberType.TAGS_FROM_ARRAY]) supporting key/value containment queries. [on] must contain exactly one member. - * - [IndexType.SET]: inverted index over a set member ([MemberType.SET]) supporting element containment queries. [on] must contain exactly one member. + * - [IndexType.TAG_MAP]: inverted index over a tags member ([MemberType.TAG_MAP] or [MemberType.TAG_MAP_FROM_ARRAY]) supporting key/value containment queries. [on] must contain exactly one member. + * - [IndexType.TAG_LIST]: inverted index over a tag-list member ([MemberType.TAG_LIST]) supporting element containment queries. [on] must contain exactly one member. * * When [internal] is `true` the index is storage-managed (e.g. primary key, id_unique). The storage * injects these into the [NakshaCollection.indices] list; clients must not declare them manually. @@ -32,14 +32,12 @@ open class Index() : AnyObject() { /** * Construct a user-defined index. * @param name the index name (must be unique within the collection). - * @param type the index type. * @param on the member names to index. * @since 3.0 */ @JsName("of") - constructor(name: String, type: IndexType, vararg on: String) : this() { + constructor(name: String, vararg on: String) : this() { this.name = name - this.type = type val cols = StringList() cols.setCapacity(on.size) for (c in on) cols.add(c) @@ -67,27 +65,6 @@ open class Index() : AnyObject() { return this } - /** - * The index type. - * @since 3.0 - */ - var type: IndexType by TYPE - - /** True iff the underlying map has an entry for [type]. */ - fun hasType(): Boolean = hasRaw("type") - - /** Remove [type] from the underlying map; returns this for chaining. */ - fun removeType(): Index { - removeRaw("type") - return this - } - - /** Fluent setter for [type]; returns this for chaining. */ - fun withType(value: IndexType): Index { - type = value - return this - } - /** * The names of the members to index. May be names of mandatory/default built-in members * (see [StandardMembers]) or names of custom [Member]s declared on the same collection. @@ -135,19 +112,19 @@ open class Index() : AnyObject() { * Whether the index enforces uniqueness across the [on] columns. Defaults to `false`. * @since 3.0 */ - var unique: Boolean by UNIQUE + private var unique: Boolean by UNIQUE /** True iff the underlying map has an entry for [unique]. */ - fun hasUnique(): Boolean = hasRaw("unique") + fun isUnique(): Boolean = unique /** Remove [unique] from the underlying map; returns this for chaining. */ - fun removeUnique(): Index { + internal fun removeUnique(): Index { removeRaw("unique") return this } /** Fluent setter for [unique]; returns this for chaining. */ - fun withUnique(value: Boolean): Index { + internal fun withUnique(value: Boolean): Index { unique = value return this } @@ -158,26 +135,25 @@ open class Index() : AnyObject() { * attempt to recreate or drop it. Defaults to `false`. * @since 3.0 */ - var internal: Boolean by INTERNAL + private var internal: Boolean by INTERNAL /** True iff the underlying map has an entry for [internal]. */ - fun hasInternal(): Boolean = hasRaw("internal") + fun isInternal(): Boolean = internal /** Remove [internal] from the underlying map; returns this for chaining. */ - fun removeInternal(): Index { + internal fun removeInternal(): Index { removeRaw("internal") return this } /** Fluent setter for [internal]; returns this for chaining. */ - fun withInternal(value: Boolean): Index { + internal fun withInternal(value: Boolean): Index { internal = value return this } companion object Index_C { private val NAME = NotNullProperty(String::class) { _, _ -> "" } - private val TYPE = NotNullEnum(IndexType::class) { _, _ -> IndexType.BTREE } private val ON = NotNullProperty(StringList::class) private val INCLUDE = NullableProperty(StringList::class) private val UNIQUE = NotNullProperty(Boolean::class) { _, _ -> false } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexList.kt index 4ce392e927..92e1c8135a 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexList.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexList.kt @@ -18,9 +18,18 @@ open class IndexList() : ListProxy(Index::class) { * Construct a list from a vararg of indexes. * @since 3.0 */ - @JsName("fromIndexes") + @JsName("of") constructor(vararg indexes: Index) : this() { - addAll(indexes.toList()) + for (index in indexes) add(index) + } + + /** + * Construct a list from a vararg of indexes. + * @since 3.0 + */ + @JsName("fromList") + constructor(indexes: List) : this() { + addAll(indexes) } companion object IndexList_C { diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexType.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexType.kt deleted file mode 100644 index 46de4becb8..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/IndexType.kt +++ /dev/null @@ -1,60 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model.objects - -import naksha.base.JsEnum -import kotlin.js.JsExport -import kotlin.jvm.JvmField -import kotlin.reflect.KClass - -/** - * The kind of index to create for an [Index]. - * - * - [BTREE] — ordered index for equality and range queries on primitive columns (numbers, booleans, strings, byte-arrays). - * - [SPATIAL] — spatial index over a geometry column (e.g. the built-in `geo`). - * - [TAGS] — inverted index over a tags column ([MemberType.TAGS] or [MemberType.TAGS_FROM_ARRAY]); - * supports key/value containment lookups. - * - [SET] — inverted index over a set column ([MemberType.SET]); supports element containment lookups. - * @since 3.0 - */ -@JsExport -class IndexType : JsEnum() { - - @Suppress("NON_EXPORTABLE_TYPE") - override fun namespace(): KClass = IndexType::class - - override fun initClass() {} - - companion object IndexType_C { - /** - * Ordered index for equality and range queries on primitive columns. - * @since 3.0 - */ - @JvmField - val BTREE = defIgnoreCase(IndexType::class, "btree") - - /** - * Spatial index over a geometry column. - * @since 3.0 - */ - @JvmField - val SPATIAL = defIgnoreCase(IndexType::class, "spatial") - - /** - * Inverted index over a [MemberType.TAGS] or [MemberType.TAGS_FROM_ARRAY] column. - * Supports key/value containment lookups. - * @since 3.0 - */ - @JvmField - val TAGS = defIgnoreCase(IndexType::class, "tags") - - /** - * Inverted index over a [MemberType.SET] column. Supports element containment lookups, - * e.g. find all features whose tags set contains the element `"foo"`. This is the default - * index for the standard `tags` member. - * @since 3.0 - */ - @JvmField - val SET = defIgnoreCase(IndexType::class, "set") - } -} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int16Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int16Member.kt new file mode 100644 index 0000000000..09b4414694 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int16Member.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.INT16 +import kotlin.js.JsName + +class Int16Member() : TypedMember() { + override fun verify(): Int16Member { + if (dataType != INT16) { + throw illegalState("The member was illegally cast, expected subtype: $INT16, found: $dataType") + } + return this + } + + /** Creates a new int16 member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = INT16 + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates an int16 member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != INT16) throw illegalArg("The given member is not of int16 type") + this.name = member.name + this.dataType = INT16 + this.path = path?.validate() ?: member.path + } + + /** Retrieves the int16 value of this member from the given feature. */ + fun get(feature: NakshaFeature): Short? = getInt64(feature)?.toShort() + + /** + * Retrieves the int16 value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Short? = getInt64(tuple)?.toShort() + + /** Sets the int16 value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Short): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int32Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int32Member.kt new file mode 100644 index 0000000000..68d6e3b854 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int32Member.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.INT32 +import kotlin.js.JsName + +class Int32Member() : TypedMember() { + override fun verify(): Int32Member { + if (dataType != INT32) { + throw illegalState("The member was illegally cast, expected subtype: $INT32, found: $dataType") + } + return this + } + + /** Creates a new int32 member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = INT32 + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates an int32 member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != INT32) throw illegalArg("The given member is not of int32 type") + this.name = member.name + this.dataType = INT32 + this.path = path?.validate() ?: member.path + } + + /** Retrieves the int32 value of this member from the given feature. */ + fun get(feature: NakshaFeature): Int? = getInt64(feature)?.toInt() + + /** + * Retrieves the int32 value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Int? = getInt64(tuple)?.toInt() + + /** Sets the int32 value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Int): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int64Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int64Member.kt new file mode 100644 index 0000000000..2993506816 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int64Member.kt @@ -0,0 +1,48 @@ +package naksha.model.objects + +import naksha.base.Int64 +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.INT64 +import kotlin.js.JsName + +class Int64Member() : TypedMember() { + override fun verify(): Int64Member { + if (dataType != INT64) { + throw illegalState("The member was illegally cast, expected subtype: $INT64, found: $dataType") + } + return this + } + + /** Creates a new int64 member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = INT64 + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates an int64 member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != INT64) throw illegalArg("The given member is not of int64 type") + this.name = member.name + this.dataType = INT64 + this.path = path?.validate() ?: member.path + } + + /** Retrieves the int64 value of this member from the given feature. */ + fun get(feature: NakshaFeature): Int64? = getInt64(feature) + + /** + * Retrieves the int64 value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Int64? = getInt64(tuple) + + /** Sets the int64 value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Int64): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int8Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int8Member.kt new file mode 100644 index 0000000000..4551d4042b --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Int8Member.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.INT8 +import kotlin.js.JsName + +class Int8Member() : TypedMember() { + override fun verify(): Int8Member { + if (dataType != INT8) { + throw illegalState("The member was illegally cast, expected subtype: $INT8, found: $dataType") + } + return this + } + + /** Creates a new int8 member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = INT8 + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates an int8 member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != INT8) throw illegalArg("The given member is not of int8 type") + this.name = member.name + this.dataType = INT8 + this.path = path?.validate() ?: member.path + } + + /** Retrieves the int8 value of this member from the given feature. */ + fun get(feature: NakshaFeature): Byte? = getInt64(feature)?.toByte() + + /** + * Retrieves the int8 value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): Byte? = getInt64(tuple)?.toByte() + + /** Sets the int8 value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: Byte): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/JsonPath.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/JsonPath.kt index 3461d3d767..69cd7aafbf 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/JsonPath.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/JsonPath.kt @@ -2,20 +2,21 @@ package naksha.model.objects -import naksha.base.ListProxy +import naksha.base.AnyList +import naksha.model.NakshaError +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaException import kotlin.js.JsExport import kotlin.js.JsName /** - * An ordered list of object keys used to walk a JSON document. + * An ordered list of keys used to walk a JSON document. All entries must be either integer or strings, all other values are invalid. * - * For example, `JsonPath("properties", "age")` resolves the value at `feature.properties.age`. - * - * Each segment must match `^[A-Za-z_][A-Za-z0-9_]*$`. There is no array indexing in v3.0 — paths can only descend into object keys. + * For example, `JsonPath("properties", "age")` resolves the value at `properties.age` or `JsonPath("properties", "data", 0)` resolves the value at `properties.data[0]` with `data` expected to be an array. * @since 3.0 */ @JsExport -open class JsonPath() : ListProxy(String::class) { +open class JsonPath() : AnyList() { /** * Construct a path from a vararg of segments. @@ -23,7 +24,22 @@ open class JsonPath() : ListProxy(String::class) { * @since 3.0 */ @JsName("fromSegments") - constructor(vararg segments: String) : this() { - addAll(segments.toList()) + constructor(vararg segments: Any) : this() { + for (segment in segments) add(segment) + } + + /** + * Tests whether the path is valid, if not, throws an [NakshaException] with [ILLEGAL_STATE] error. Actually, the path must only contain strings and integers. + * @return this. + * @since 3.0 + */ + fun validate(): JsonPath { + for (i in 0 until this.size) { + val segment = this[i] ?: throw NakshaException(ILLEGAL_STATE, "Illegal NULL at path position $i") + if (segment !is String && segment !is Int) { + throw NakshaException(ILLEGAL_STATE, "Illegal value at path position $i, must be string or integer, found: '$segment'") + } + } + return this } -} +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Member.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Member.kt index 8d9fb95621..55d4bba08f 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Member.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/Member.kt @@ -2,43 +2,79 @@ package naksha.model.objects +import naksha.base.AnyList import naksha.base.AnyObject +import naksha.base.Int64 +import naksha.base.ListProxy +import naksha.base.MapProxy import naksha.base.NotNullEnum import naksha.base.NotNullProperty import naksha.base.NullableProperty +import naksha.base.PlatformList +import naksha.base.PlatformMap +import naksha.base.Proxy +import naksha.geo.SpGeometry +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaException +import naksha.model.NakshaIdType.INTERNAL_MEMBER +import naksha.model.TagList +import naksha.model.TagMap +import naksha.model.Tuple +import naksha.model.TupleNumber import kotlin.js.JsExport import kotlin.js.JsName +// TODO: We need an immutable version of this, because this actually allows mutation. + /** - * A column materialized on a [NakshaCollection] — either a mandatory/default built-in column or a - * user-defined one. + * A materialized part of a feature. * - * At write time, the storage walks the feature using [map], extracts the value, coerces it to the - * [dataType], and stores it in a storage-specific column derived from [name]. The value also remains - * in the encoded feature blob. + * At write time, the storage walks the feature using [path], extracts the value, coerces it to the [dataType], and stores it in a storage-specific location derived from the [name]. The value is removed from the feature, and instead of the actual value a reference into the dedicated storage place is added, so that when decoding the feature, it can be copied back. * - * The [name] must be a valid Naksha identifier (see [naksha.model.Naksha.verifyId]). - * Mandatory columns (e.g. `fn`, `version`, `id`, `feature`) are injected by the storage and must - * not be redeclared by the client with a different type. + * The [name] must be a valid Naksha identifier (see [naksha.model.Naksha.verifyId]). Mandatory members are injected by the storage and **must not** be redeclared by the client with a different type. Mandatory members are: + * - [Tuple-Number][naksha.model.objects.StandardMembers.StandardMembers_C.Tn] + * - [NextVersion][naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion] + * - [GlobalBookFeatureNumber][naksha.model.objects.StandardMembers.StandardMembers_C.GlobalBookFeatureNumber] + * - [Id][naksha.model.objects.StandardMembers.StandardMembers_C.Id] + * - [Feature][naksha.model.objects.StandardMembers.StandardMembers_C.Feature] * - * If [map] is not set, the storage defaults to `["properties", ]` at write time. + * If [path] is not explicitly set, the implicit path defaults to `["properties", ]`. * @since 3.0 */ @JsExport -open class Member() : AnyObject() { +open class Member() : AnyObject(), Comparator { /** * Construct a member with a name and the given data type. * @param name the member name. * @param dataType the data type; defaults to [MemberType.STRING]. - * @param map the JSON path to read the value from; defaults to `["properties", name]` when null. + * @param path the JSON path to read the value from; defaults to `["properties", name]` when null. * @since 3.0 */ @JsName("of") - constructor(name: String, dataType: MemberType = MemberType.STRING, map: JsonPath? = null) : this() { - this.name = name + constructor(name: String, dataType: MemberType = MemberType.STRING, path: JsonPath? = null) : this() { + this.name = INTERNAL_MEMBER.verify(name) this.dataType = dataType - if (map != null) this.map = map + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** + * Construct a member copy with a different path, used to relocate standard members to different places. + * + * Specifically, this is used to relocate the Tuple-Number and other mandatory members into the deprecated XYZ namespace. + * @param origin The member to create a copy of. + * @param path The new path to relocate the member to. + * @since 3.0 + */ + @JsName("relocate") + constructor(origin: Member, path: JsonPath) : this() { + if (origin.isVirtual()) throw NakshaException(ILLEGAL_ARGUMENT, "Virtual members can't be copied") + this.name = origin.name + this.dataType = origin.dataType + this.path = path + this.path.validate() } /** @@ -69,6 +105,14 @@ open class Member() : AnyObject() { */ var dataType: MemberType by DATA_TYPE + /** + * The index of this member in the storage; the index is set by the storage ones a collection is created. + * + * This value is maintained by the storage; it **MUST NOT** be modified by clients. Storages may manage this value or ignore it, this is implementation dependent. Changes done by the client are ignored by the storage. + * @since 3.0 + */ + val index: Int? by INDEX + /** True iff the underlying map has an entry for [dataType]. */ fun hasDataType(): Boolean = hasRaw("dataType") @@ -90,38 +134,426 @@ open class Member() : AnyObject() { * Each segment must match `^[A-Za-z_][A-Za-z0-9_]*$`. There is no array indexing in v3.0. * @since 3.0 */ - var map: JsonPath? by MAP + var path: JsonPath by PATH + + /** Fluent setter for [path]; returns this for chaining. */ + fun withPath(value: JsonPath?): Member { + path = value ?: JsonPath(listOf("properties", name)) + return this + } + + /** + * Read this member from the given proxy using the [path] of this member. + * @param proxy The proxy to read. + * @return the value of member in that proxy. + */ + fun read(proxy: Proxy): Any? = proxy.getPath(path) + + /** + * Whether this member is a storage-managed mandatory member. When `true`, the storage controls the DDL for this member. Defaults to `false`. + * @since 3.0 + */ + private var mandatory: Boolean by MANDATORY + + /** True if this member is mandatory. */ + fun isMandatory(): Boolean = mandatory + + internal var virtual: Boolean = false + internal fun withVirtual(): Member { + this.virtual = true + return this + } - /** True iff the underlying map has an entry for [map]. */ - fun hasMap(): Boolean = hasRaw("map") + /** + * True if this member is a virtual member. There are only + */ + fun isVirtual(): Boolean = mandatory - /** Remove [map] from the underlying map; returns this for chaining. */ - fun removeMap(): Member { - removeRaw("map") + /** Remove [mandatory] from the underlying map; returns this for chaining. */ + internal fun removeMandatory(): Member { + removeRaw("mandatory") return this } - /** Fluent setter for [map]; returns this for chaining. */ - fun withMap(value: JsonPath?): Member { - map = value + /** Fluent setter for [mandatory]; returns this for chaining. */ + internal fun withMandatory(value: Boolean = true): Member { + mandatory = value return this } /** - * Returns the effective JSON path to read this member from a feature. - * - * If [map] is explicitly set, returns its contents; otherwise returns `["properties", name]`. + * Check if this member is the same as the given one, so [name] and [dataType] match. + * @param other the other member to test against. + * @return _true_ if the two members are the same; _false_ otherwise. * @since 3.0 */ - fun effectivePath(): List { - val m = map - return if (m != null && m.isNotEmpty()) m.filterNotNull().toList() - else listOf("properties", name) + infix fun eq(other: Member?): Boolean = isSameAs(other) + + /** + * Ensures that the given `found` member is the same as the `expected` member, allow different path. + * @param other The member to compare this member with. + * @param comparePath If the path must be the same as well, defaults to _false_. + * @return _true_ if the two members are the same; _false_ otherwise. + */ + fun isSameAs(other: Member?, comparePath: Boolean = false): Boolean { + if (other == null) return false + if (this === other) return true + // We require same name and data-type, but not same JSON path. + if (name != other.name) return false + if (dataType != other.dataType) return false + if (comparePath && !path.contentDeepEquals(other.path)) return false + return true + } + + /** + * Ensures that the given `other` member is the same as this. + * + * The purpose of the method is generally to do: + * ```kotlin + * val member = StandardMember.Id.asSame(someCustomMember) + * ``` + * Which will return the given custom member, ensuring that it still is the standard `id` member, but allows different paths. + * @param other The member to compare this with. + * @param comparePath If the path must be the same as well, defaults to _false_. + * @return The `other` member, if it is the same as this. + * @throws NakshaException with error [ILLEGAL_STATE], when the given `other` members does not match this member. + */ + fun asSame(other: Member?, comparePath: Boolean = false): Member { + if (other == null) throw NakshaException(ILLEGAL_STATE, "The other member is NULL") + if (other === this) return other + // We require same name and data-type, but not same JSON path. + if (name != other.name) { + throw NakshaException(ILLEGAL_STATE, "The other member has different name: '${other.name}', expected: '${name}'") + } + if (dataType != other.dataType) { + throw NakshaException(ILLEGAL_STATE, "The other member has wrong data type: '${other.dataType}', expected '${dataType}'") + } + if (comparePath && !path.contentDeepEquals(other.path)) { + throw NakshaException(ILLEGAL_STATE, "The other member has a different path: '${other.path.joinToString("->")}', expected: '${path.joinToString("->")}'") + } + return other + } + + /** + * Helper to read a [TupleNumber] form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getTupleNumber(feature: MapProxy<*,*>): TupleNumber? { + val raw = feature.getPath(path) + if (raw is TupleNumber) return raw + if (raw is String) return TupleNumber.fromString(raw) + if (raw is ByteArray) return TupleNumber.fromByteArray(raw) + return null + } + + /** + * Helper to read a string form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getBoolean(feature: MapProxy<*,*>): Boolean? { + val raw = feature.getPath(path) + if (raw is Boolean) return raw + return null + } + + /** + * Helper to read a string form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getString(feature: MapProxy<*,*>): String? { + val raw = feature.getPath(path) + if (raw is String) return raw + return null + } + + /** + * Helper to read a 64-bit integer form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getInt64(feature: MapProxy<*,*>): Int64? { + val raw = feature.getPath(path) + if (raw is Int64) return raw + if (raw is Long) return Int64(raw) + if (raw is Number) return Int64(raw.toLong()) + return null + } + + /** + * Helper to read a 64-bit floating point number form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getDouble(feature: MapProxy<*,*>): Double? { + val raw = feature.getPath(path) + if (raw is Double) return raw + if (raw is Number) return raw.toDouble() + return null + } + + /** + * Helper to read a 64-bit floating point number form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getGeometry(feature: MapProxy<*,*>): SpGeometry? { + val raw = feature.getPath(path) + if (raw is SpGeometry) return raw + return null + } + + /** + * Helper to read a 64-bit floating point number form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getByteArray(feature: MapProxy<*,*>): ByteArray? { + val raw = feature.getPath(path) + if (raw is ByteArray) return raw + return null + } + + /** + * Helper to read a [TagMap] form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getTagMap(feature: MapProxy<*,*>): TagMap? { + val raw = feature.getPath(path) + if (raw is TagMap) return raw + if (raw is MapProxy<*,*>) return raw.proxy(TagMap::class) + if (raw is PlatformMap) return raw.proxy(TagMap::class) + return null + } + + /** + * Helper to read a [TagMap] form the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getTagList(feature: MapProxy<*,*>): TagList? { + val raw = feature.getPath(path) + if (raw is TagList) return raw + if (raw is ListProxy<*>) return raw.proxy(TagList::class) + if (raw is PlatformList) return raw.proxy(TagList::class) + return null + } + + /** + * Helper to read a [TupleNumber] from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getTupleNumberFromTuple") + fun getTupleNumber(tuple: Tuple): TupleNumber? { + val raw = tuple.membersBook[this.name] ?: return null + if (raw is TupleNumber) return raw + if (raw is String) return TupleNumber.fromString(raw) + if (raw is ByteArray) return TupleNumber.fromByteArray(raw) + return null + } + + /** + * Helper to read a boolean from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getBooleanFromTuple") + fun getBoolean(tuple: Tuple): Boolean? { + val raw = tuple.membersBook[this.name] + if (raw is Boolean) return raw + return null + } + + /** + * Helper to read a string from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getStringFromTuple") + fun getString(tuple: Tuple): String? { + val raw = tuple.membersBook[this.name] + if (raw is String) return raw + return null + } + + /** + * Helper to read a 64-bit integer from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getInt64FromTuple") + fun getInt64(tuple: Tuple): Int64? { + val raw = tuple.membersBook[this.name] + if (raw is Int64) return raw + if (raw is Long) return Int64(raw) + if (raw is Number) return Int64(raw.toLong()) + return null + } + + /** + * Helper to read a 64-bit floating point number from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getDoubleFromTuple") + fun getDouble(tuple: Tuple): Double? { + val raw = tuple.membersBook[this.name] + if (raw is Double) return raw + if (raw is Number) return raw.toDouble() + return null + } + + /** + * Helper to read a geometry from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getGeometryFromTuple") + fun getGeometry(tuple: Tuple): SpGeometry? { + val raw = tuple.membersBook[this.name] + if (raw is SpGeometry) return raw + return null + } + + /** + * Helper to read a byte array from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getByteArrayFromTuple") + fun getByteArray(tuple: Tuple): ByteArray? { + val raw = tuple.membersBook[this.name] + if (raw is ByteArray) return raw + return null + } + + /** + * Helper to read a [TagMap] from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getTagMapFromTuple") + fun getTagMap(tuple: Tuple): TagMap? { + val raw = tuple.membersBook[this.name] + if (raw is TagMap) return raw + if (raw is MapProxy<*,*>) return raw.proxy(TagMap::class) + if (raw is PlatformMap) return raw.proxy(TagMap::class) + return null + } + + /** + * Helper to read a [TagList] from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getTagListFromTuple") + fun getTagList(tuple: Tuple): TagList? { + val raw = tuple.membersBook[this.name] + if (raw is TagList) return raw + if (raw is ListProxy<*>) return raw.proxy(TagList::class) + if (raw is PlatformList) return raw.proxy(TagList::class) + return null + } + + /** + * Helper to write a member value to the given feature. + * @param feature The feature to write to. + * @return the previous value. + * Helper to read a set from the given feature. + * @param feature The feature to read from. + * @return the read value or `null`, if the feature does not store a valid value at the member path. + */ + fun getSet(feature: MapProxy<*,*>): AnyList? { + val raw = feature.getPath(path) + if (raw is AnyList) return raw + if (raw is ListProxy<*>) return raw.proxy(AnyList::class) + if (raw is PlatformList) return raw.proxy(AnyList::class) + return null + } + + /** + * Helper to read a set from the given tuple. + * @param tuple The tuple to read from. + * @return the read value or `null`, if the tuple does not store a valid value for this member. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getSetFromTuple") + fun getSet(tuple: Tuple): AnyList? { + val raw = tuple.membersBook[this.name] + if (raw is AnyList) return raw + if (raw is ListProxy<*>) return raw.proxy(AnyList::class) + if (raw is PlatformList) return raw.proxy(AnyList::class) + return null } + /** + * Helper to write a member value to the given feature. + * @param feature The feature to write to. + * @return the previous value. + * @throws RuntimeException If the given feature has a broken path, so the path requires an array, but an object exists already. + */ + fun set(feature: MapProxy<*,*>, value: Any?): Any? = feature.setPath(value, path) + + override fun compare(a: Member, b: Member): Int = a.dataType.sortOrder - b.dataType.sortOrder + companion object Member_C { private val NAME = NotNullProperty(String::class) { _, _ -> "" } private val DATA_TYPE = NotNullEnum(MemberType::class) { _, _ -> MemberType.STRING } - private val MAP = NullableProperty(JsonPath::class) + private val INDEX = NullableProperty(Int::class) + private val PATH = NotNullProperty(JsonPath::class) { self, _ -> JsonPath(listOf("properties", self.name)) } + private val MANDATORY = NotNullProperty(Boolean::class) { _, _ -> false } + } + + /** + * Returns the concrete subtype of this member. + * @return the concrete subtype of this member, for example [BoolMember] + * @since 3.0 + */ + fun subType(): Member { + val klass = this::class + if (klass != Member::class) return this + when (dataType) { + MemberType.BOOLEAN -> proxy(BoolMember::class) + MemberType.INT8 -> proxy(Int8Member::class) + MemberType.INT16 -> proxy(Int16Member::class) + MemberType.INT32 -> proxy(Int32Member::class) + MemberType.INT64 -> proxy(Int64Member::class) + MemberType.FLOAT32 -> proxy(Float32Member::class) + MemberType.FLOAT64 -> proxy(Float64Member::class) + MemberType.STRING -> proxy(StringMember::class) + MemberType.BYTE_ARRAY -> proxy(ByteArrayMember::class) + MemberType.TUPLE_NUMBER -> proxy(TupleNumberMember::class) + MemberType.SPATIAL -> proxy(SpatialMember::class) + MemberType.TAG_MAP, MemberType.TAG_MAP_FROM_ARRAY -> proxy(TagsMember::class) + MemberType.TAG_LIST -> proxy(TagListMember::class) + } + return this } + + fun asBool(): BoolMember = proxy(BoolMember::class) + fun asInt8(): Int8Member = proxy(Int8Member::class) + fun asInt16(): Int16Member = proxy(Int16Member::class) + fun asInt32(): Int32Member = proxy(Int32Member::class) + fun asInt64(): Int64Member = proxy(Int64Member::class) + fun asFloat32(): Float32Member = proxy(Float32Member::class) + fun asFloat64(): Float64Member = proxy(Float64Member::class) + fun asString(): StringMember = proxy(StringMember::class) + fun asByteArray(): ByteArrayMember = proxy(ByteArrayMember::class) + fun asTupleNumber(): TupleNumberMember = proxy(TupleNumberMember::class) + fun asSpatial(): SpatialMember = proxy(SpatialMember::class) + fun asTags(): TagsMember = proxy(TagsMember::class) + fun asTagList(): TagListMember = proxy(TagListMember::class) } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberList.kt index 6097a0d512..a4afa5fcef 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberList.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberList.kt @@ -3,6 +3,12 @@ package naksha.model.objects import naksha.base.ListProxy +import naksha.model.Naksha +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaError.NakshaErrorCompanion.INTERNAL_ERROR +import naksha.model.NakshaException +import naksha.model.NakshaIdType +import naksha.model.NakshaIdType.INTERNAL_MEMBER import kotlin.js.JsExport import kotlin.js.JsName import kotlin.jvm.JvmStatic @@ -20,7 +26,16 @@ open class MemberList() : ListProxy(Member::class) { */ @JsName("fromMembers") constructor(vararg members: Member) : this() { - addAll(members.toList()) + for (member in members) add(member) + } + + /** + * Construct a list from a list of members. + * @since 3.0 + */ + @JsName("fromList") + constructor(members: List) : this() { + addAll(members) } companion object MemberList_C { @@ -34,4 +49,95 @@ open class MemberList() : ListProxy(Member::class) { fun of(vararg members: Member): MemberList = MemberList().apply { addAll(members.toList()) } } + + /** + * Sort this list by the sort-order of the [MemberType] and updates the `index` of all members. + * @return this. + * @since 3.0 + * @throws NakshaException with error [ILLEGAL_STATE], if any member is `null` or has no `dataType`. + */ + fun sortByDataTypeAndAssignIndex(): MemberList { + sortBy { member -> member?.dataType?.sortOrder ?: throw NakshaException(ILLEGAL_STATE, "Member is null or has no dataType") } + // Save the order. + for (i in 0 until size) { + val member = this[i] ?: throw NakshaException(INTERNAL_ERROR, "Member is null, that must not happen, should have caught before") + member["index"] = i + } + return this + } + + /** + * Sort this list by the index of the [MemberType]. + * @return this. + * @since 3.0 + * @throws NakshaException with error [ILLEGAL_STATE], if any member is `null` or has no valid `index`. + */ + fun sortByIndex(): MemberList { + sortBy { member -> member?.index ?: throw NakshaException(ILLEGAL_STATE, "Member is null or has no index") } + return this + } + + /** + * Tests if this list is sorted by [data-type][sortByDataTypeAndAssignIndex]. + * @return _true_ if the entries are sorted; _false_ otherwise. + */ + fun isSortedByDataType(): Boolean { + val END = this.size + var max = 0 + for (i in 0 until END) { + val member = this[i] ?: return false + val sortOrder = member.dataType.sortOrder + // The elements towards the end of the list must have the biggest sort-order value, we always sort ascending. + // This results in members in byte-alignment order, so INT8, FLOAT8, INT4, FLOAT4, ... + // This prevents padding in the database. + if (max > sortOrder) return false + max = sortOrder + } + return true + } + + /** + * Tests if this list is sorted by [index][sortByIndex]. + * @return _true_ if the entries are sorted; _false_ otherwise. + */ + fun isSortedByIndex(): Boolean { + val END = this.size + for (i in 0 until END) { + val member = this[i] ?: return false + val index = member.index ?: return false + if (index != i) return false + } + return true + } + + /** + * Get the member with the given name from this list. + * @param name The name of the member. + * @return The first member with that name or `null`, if no such member was found. + */ + fun get(name: String): Member? { + for (member in this) { + if (member != null && member.name == name) return member + } + return null + } + + /** + * Test whether this member list is valid, so does not have `null` entries and all members have unique valid names. Throws a [NakshaException], if any error is found. + */ + fun validate() { + for (i in 0 until this.size) { + val member = this[i] ?: throw NakshaException(ILLEGAL_STATE, "Member at index $i is null") + val memberName = member.name + if (INTERNAL_MEMBER.isValidId(memberName)) { + throw NakshaException(ILLEGAL_STATE, "Member at index $i has invalid name: $memberName") + } + for (j in (i + 1) until this.size) { + val later = this[j] ?: throw NakshaException(ILLEGAL_STATE, "Member at index $j is null") + if (memberName == later.name) { + throw NakshaException(ILLEGAL_STATE, "Member at index $i has same name as member at $j: $memberName") + } + } + } + } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberType.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberType.kt index f818adb07f..22c77a1fe6 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberType.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/MemberType.kt @@ -2,7 +2,13 @@ package naksha.model.objects +import naksha.base.Int64 import naksha.base.JsEnum +import naksha.geo.SpGeometry +import naksha.model.NakshaError.NakshaErrorCompanion.INITIALIZATION_FAILED +import naksha.model.NakshaException +import naksha.model.TagMap +import naksha.model.TupleNumber import kotlin.js.JsExport import kotlin.jvm.JvmField import kotlin.reflect.KClass @@ -13,20 +19,21 @@ import kotlin.reflect.KClass * - Primitives: [BOOLEAN], [INT8], [INT16], [INT32], [INT64], [FLOAT32], [FLOAT64], [STRING], [BYTE_ARRAY]. * - [SPATIAL]: a geometry stored as raw TWKB bytes. The storage persists this as a binary column and * supports spatial queries. Only a [IndexType.SPATIAL] index may be placed on a [SPATIAL] member. - * - [TAGS]: a map whose keys are strings and values are primitives (matches JBON2 tag-map specification). + * - [TAG_MAP]: a map whose keys are strings and values are primitives (matches JBON2 tag-map specification). * The storage persists this as a flat key/value map that supports containment queries. - * - [TAGS_FROM_ARRAY]: like [TAGS] but the input is a `TagList` (Naksha tag-array syntax, e.g. + * - [TAG_MAP_FROM_ARRAY]: like [TAG_MAP] but the input is a `TagList` (Naksha tag-array syntax, e.g. * `["key=value", "name:=42"]`). The list is converted to a tag-map at write time and stored in the - * same flat key/value representation as [TAGS]. Beware that the conversion has a side effect: + * same flat key/value representation as [TAG_MAP]. Beware that the conversion has a side effect: * reading the feature back returns the tags re-flattened from the map, so the original array * order is **not** preserved. * Only valid as a [Member] type; not a valid [IndexType]. - * To index a [TAGS_FROM_ARRAY] column use [IndexType.TAGS]. - * - [SET]: a JSON array of unique primitive values (booleans, numbers, strings). The array is stored - * unmodified, so the element order is preserved when reading the feature back. Supports - * element-containment queries via [IndexType.SET]. This is the default type of the standard - * `tags` member, which keeps 100% downward compatibility with the classic XYZ tags array at - * `properties -> @ns:com:here:xyz -> tags`. + * To index a [TAG_MAP_FROM_ARRAY] column use [IndexType.TAG_MAP]. + * - [TAG_LIST]: a list of unique primitive values (booleans, numbers, strings). Value order is + * significant, but must not have duplicates, null or undefined. The storage persists the list + * unmodified, so the element order is guaranteed to be preserved when reading the feature back. + * Supports element-containment queries via [IndexType.TAG_LIST]. This is the default type of the + * standard `tags` member, which keeps 100% downward compatibility with the classic XYZ tags array + * at `properties -> @ns:com:here:xyz -> tags`. * @since 3.0 */ @JsExport @@ -43,63 +50,70 @@ class MemberType : JsEnum() { * @since 3.0 */ @JvmField - val BOOLEAN = defIgnoreCase(MemberType::class, "boolean") + val BOOLEAN = defIgnoreCase(MemberType::class, "boolean") { self -> self.sortOrder = 6; self.subtype = BoolMember::class } /** * 8-bit signed integer in storage, but when reading from book, decode as long. * @since 3.0 */ @JvmField - val INT8 = defIgnoreCase(MemberType::class, "int8") + val INT8 = defIgnoreCase(MemberType::class, "int8") { self -> self.sortOrder = 5; self.subtype = Int8Member::class } /** * 16-bit signed integer in storage, but when reading from book, decode as long. * @since 3.0 */ @JvmField - val INT16 = defIgnoreCase(MemberType::class, "int16") + val INT16 = defIgnoreCase(MemberType::class, "int16") { self -> self.sortOrder = 4; self.subtype = Int16Member::class } /** * 32-bit signed integer in storage, but when reading from book, decode as long. * @since 3.0 */ @JvmField - val INT32 = defIgnoreCase(MemberType::class, "int32") + val INT32 = defIgnoreCase(MemberType::class, "int32") { self -> self.sortOrder = 2; self.subtype = Int32Member::class } /** * 64-bit signed integer. * @since 3.0 */ @JvmField - val INT64 = defIgnoreCase(MemberType::class, "int64") + val INT64 = defIgnoreCase(MemberType::class, "int64") { self -> self.sortOrder = 0; self.subtype = Int64Member::class } /** * 32-bit IEEE-754 floating point in storage, but when reading from book, decode as double. * @since 3.0 */ @JvmField - val FLOAT32 = defIgnoreCase(MemberType::class, "float32") + val FLOAT32 = defIgnoreCase(MemberType::class, "float32"){ self -> self.sortOrder = 3; self.subtype = Float32Member::class } /** * 64-bit IEEE-754 floating point. * @since 3.0 */ @JvmField - val FLOAT64 = defIgnoreCase(MemberType::class, "float64") + val FLOAT64 = defIgnoreCase(MemberType::class, "float64") { self -> self.sortOrder = 1; self.subtype = Float64Member::class } /** * Variable-length string. * @since 3.0 */ @JvmField - val STRING = defIgnoreCase(MemberType::class, "string") + val STRING = defIgnoreCase(MemberType::class, "string") { self -> self.sortOrder = 7; self.subtype = StringMember::class } /** * Raw byte array. * @since 3.0 */ @JvmField - val BYTE_ARRAY = defIgnoreCase(MemberType::class, "byte_array") + val BYTE_ARRAY = defIgnoreCase(MemberType::class, "byte_array") { self -> self.sortOrder = 13; self.subtype = ByteArrayMember::class } + + /** + * A tuple-number, can be encoded as string or byte-array _(storage decides)_. To be used with [IndexType.BTREE]. + * @since 3.0 + */ + @JvmField + val TUPLE_NUMBER = defIgnoreCase(MemberType::class, "tuple_number") { self -> self.sortOrder = 12; self.subtype = TupleNumberMember::class } /** * A geometry stored as raw [TWKB](https://github.com/nicowillis/twkb) bytes. @@ -111,48 +125,102 @@ class MemberType : JsEnum() { * @since 3.0 */ @JvmField - val SPATIAL = defIgnoreCase(MemberType::class, "spatial") + val SPATIAL = defIgnoreCase(MemberType::class, "spatial") { self -> self.sortOrder = 11; self.subtype = SpatialMember::class } /** * A map whose keys are strings and values are primitives, following the JBON2 tag-map * specification. The storage persists this as a flat key/value map that supports * containment queries. * - * Indexed via [IndexType.TAGS]. + * Indexed via [IndexType.TAG_MAP]. * @since 3.0 */ @JvmField - val TAGS = defIgnoreCase(MemberType::class, "tags") + val TAG_MAP = defIgnoreCase(MemberType::class, "tag_map") { self -> self.sortOrder = 8; self.subtype = TagsMember::class } /** - * A string-array using Naksha tag syntax that is expanded into a [TAGS] map at write time. + * A string-array using Naksha tag syntax that is expanded into a [TAG_MAP] map at write time. * Provided for downward compatibility with XYZ Hub and previous Naksha v2 clients that send * tags as arrays rather than maps. * * Input is `["key=value", "name:=42"]`; materialized form is `{"key":"value","name":42}`. - * Stored in the same flat key/value representation as [TAGS]. + * Stored in the same flat key/value representation as [TAG_MAP]. * - * **Not valid as an [IndexType].** To index this column use [IndexType.TAGS]. + * **Not valid as an [IndexType].** To index this column use [IndexType.TAG_MAP]. * @since 3.0 */ @JvmField - val TAGS_FROM_ARRAY = defIgnoreCase(MemberType::class, "tags_from_array") + val TAG_MAP_FROM_ARRAY = defIgnoreCase(MemberType::class, "tag_map_from_array") { self -> self.sortOrder = 9; self.subtype = TagsMember::class } /** - * A JSON array of unique primitive values (booleans, numbers, strings), following the JBON2 - * set specification: entries must not be `null` or duplicates, and the order is significant. - * The storage persists the array unmodified (as a JSON array in `jsonb`), so the element - * order is guaranteed to be preserved when reading the feature back. + * A list of unique primitive values (booleans, numbers, strings), following the JBON2 + * TagList specification: entries must not be `null` or duplicates, and the order is + * significant. The storage persists the list unmodified (as a JSON array in `jsonb`), so + * the element order is guaranteed to be preserved when reading the feature back. * * This is the default type of the standard `tags` member (the classic XYZ tags array at * `properties -> @ns:com:here:xyz -> tags`, e.g. `["foo", "bar"]`). In contrast to - * [TAGS_FROM_ARRAY] the values are not split into key/value pairs, therefore only full + * [TAG_MAP_FROM_ARRAY] the values are not split into key/value pairs, therefore only full * elements can be searched, not keys or values. * - * Indexed via [IndexType.SET]. + * Indexed via [IndexType.TAG_LIST]. * @since 3.0 */ @JvmField - val SET = defIgnoreCase(MemberType::class, "set") + val TAG_LIST = defIgnoreCase(MemberType::class, "tag_list") { self -> self.sortOrder = 10; self.subtype = TagListMember::class } + } + + /** + * The concrete subtype of this class. + * @since 3.0 + */ + var subtype: KClass = Member::class + private set + + /** + * Tests if the given value is of this type. + * @param value the value to test. + * @return `true` if the value is of this type; false otherwise. + */ + fun isInstance(value: Any?): Boolean { + if (value == null) return false + return when (this) { + BOOLEAN -> value is Boolean + INT8 -> value is Byte + INT16 -> value is Byte || value is Short + INT32 -> value is Byte || value is Short || value is Int + INT64 -> value is Byte || value is Short || value is Int || value is Long || value is Int64 + FLOAT32 -> value is Float + FLOAT64 -> value is Float || value is Double + STRING -> value is String + BYTE_ARRAY -> value is ByteArray + TUPLE_NUMBER -> value is TupleNumber + SPATIAL -> value is SpGeometry + TAG_MAP, TAG_MAP_FROM_ARRAY -> value is TagMap + TAG_LIST -> value is List<*> + else -> false + } } + + /** + * The sort order of the type to ensure no padding is needed, so `INT8`, `FLOAT8`, `INT4`, `FLOAT4`, ... + * @since 3.0 + */ + var sortOrder: Int = -1 + private set(value) { + val it: Iterator = iterate(MemberType::class) + var biggestSortOrder = -1 + while (it.hasNext()) { + val memberType = it.next() + if (memberType.sortOrder == value) { + throw NakshaException(INITIALIZATION_FAILED, "Found duplicate sortOrder, member $this == $memberType") + } + if (memberType.sortOrder > biggestSortOrder) biggestSortOrder = memberType.sortOrder + } + var expected = biggestSortOrder+1 + if (value != biggestSortOrder) { + throw NakshaException(INITIALIZATION_FAILED, "Member $this is expected to have sortOrder $expected, but has $value") + } + field = value + } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCatalog.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCatalog.kt new file mode 100644 index 0000000000..c1c3856a39 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCatalog.kt @@ -0,0 +1,105 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.objects + +import naksha.base.Int64 +import naksha.base.NullableProperty +import naksha.geo.SpBoundingBox +import naksha.geo.SpGeometry +import naksha.geo.SpPoint +import naksha.model.Naksha +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaException +import naksha.model.TupleNumber +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * A map within a storage; maps are used to group collections. + * @since 3.0 + */ +@JsExport +open class NakshaCatalog() : NakshaFeature() { + + /** + * Create a new map feature with the given identifier. + * @param id the identifier to set. + * @since 3.0 + */ + @Suppress("LeakingThis") + @JsName("of") + constructor(id: String): this() { + this.id = id + this.type = typeDefaultValue() + this.featureType = featureTypeDefaultValue() + } + + companion object NakshaMap_C { + /** + * The feature-type of this feature itself _(`naksha.Catalog`)_. + * @since 3.0 + */ + const val FEATURE_TYPE = "naksha.Catalog" + + private val DATABASE_ID = NullableProperty(String::class) + } + + override fun featureTypeDefaultValue(): String = FEATURE_TYPE + override fun withId(value: String): NakshaCatalog = super.withId(value) as NakshaCatalog + override fun withType(value: String): NakshaCatalog = super.withType(value) as NakshaCatalog + override fun withFeatureType(value: String): NakshaCatalog = super.withFeatureType(value) as NakshaCatalog + override fun withBbox(value: SpBoundingBox?): NakshaCatalog = super.withBbox(value) as NakshaCatalog + override fun withGeometry(value: SpGeometry?): NakshaCatalog = super.withGeometry(value) as NakshaCatalog + override fun withReferencePoint(value: SpPoint?): NakshaCatalog = super.withReferencePoint(value) as NakshaCatalog + override fun withProperties(value: NakshaProperties): NakshaCatalog = super.withProperties(value) as NakshaCatalog + override fun withMomType(value: String?): NakshaCatalog = super.withMomType(value) as NakshaCatalog + + /** + * Helper to get/set the [TupleNumber] of the catalog-feature. All catalogs features follow the old XYZ-Hub style, therefore the location of the [TupleNumber] is clear. + * @since 3.0 + */ + var tupleNumber: TupleNumber? + get() = XyzMembers.XyzTn.getTupleNumber(this) + set(value) { + XyzMembers.XyzTn.set(this, value) + } + + /** + * The database-number of the catalog; the catalog-feature itself is stored in the same database as the catalog it describes. + * @since 3.0 + * @throws NakshaException with error [ILLEGAL_STATE], when the collection does not have a valid [tupleNumber]. + */ + val databaseNumber: Int64 + get() = tupleNumber?.databaseNumber ?: throw NakshaException(ILLEGAL_STATE, "The collection has no tuple-number") + + /** + * The database-id of the collection; **NOT** the database-id of the collection-feature itself, even while they are guaranteed to be the same. + * @since 3.0 + */ + var databaseId: String? by DATABASE_ID + + /** + * @see [databaseId] + */ + fun withDatabaseId(value: String): NakshaCatalog { + val tn = tupleNumber + if (tn != null) { + if (Naksha.databaseNumber(value) != tn.databaseNumber) { + throw NakshaException(ILLEGAL_ARGUMENT, "The given database-id does not match the database-number of the collection.") + } + } + databaseId = value + return this + } + + /** + * The catalog-number of the catalog, this is actually the same as the feature-number. + * + * It is **NOT** the catalog-number of this catalog-feature, so where the catalog-feature itself is stored, which has always the catalog-number `0`, because all catalog features are always stored in the catalog `naksha~admin`. + * @since 3.0 + * @see [Naksha.catalogNumber] + */ + val catalogNumber: Int + get() = Naksha.catalogNumber(id) +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCollection.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCollection.kt index f68ba1db18..b83cc017ae 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCollection.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaCollection.kt @@ -6,10 +6,13 @@ import naksha.base.* import naksha.geo.SpBoundingBox import naksha.geo.SpGeometry import naksha.geo.SpPoint -import naksha.model.DataEncoding import naksha.model.Naksha import naksha.model.NakshaError +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE import naksha.model.NakshaException +import naksha.model.NakshaIdType +import naksha.model.TupleNumber import kotlin.js.JsExport import kotlin.js.JsName import kotlin.js.JsStatic @@ -45,7 +48,7 @@ open class NakshaCollection() : NakshaFeature() { storeMeta: StoreMode = StoreMode.ON, ) : this() { this.id = id - this.mapId = mapId + this.catalogId = mapId this.storageClass = storageClass this.partitions = partitions this.storeDeleted = storeDeleted @@ -55,7 +58,6 @@ open class NakshaCollection() : NakshaFeature() { override fun featureTypeDefaultValue(): String = FEATURE_TYPE override fun withId(value: String): NakshaCollection = super.withId(value) as NakshaCollection - override fun withFeatureNumber(value: Int64): NakshaCollection = super.withFeatureNumber(value) as NakshaCollection override fun withType(value: String): NakshaCollection = super.withType(value) as NakshaCollection override fun withFeatureType(value: String): NakshaCollection = super.withFeatureType(value) as NakshaCollection override fun withBbox(value: SpBoundingBox?): NakshaCollection = super.withBbox(value) as NakshaCollection @@ -64,38 +66,83 @@ open class NakshaCollection() : NakshaFeature() { override fun withProperties(value: NakshaProperties): NakshaCollection = super.withProperties(value) as NakshaCollection override fun withMomType(value: String?): NakshaCollection = super.withMomType(value) as NakshaCollection - override fun featureNumberOfId(id: String): Int64 = Naksha.collectionNumber(id).toInt64() + /** + * Helper to get/set the [TupleNumber] of the collection-feature. All collection features follow the old XYZ-Hub style, therefore the location of the [TupleNumber] is clear at `properties->@ns:com:here:xyz->uuid`. + * @since 3.0 + */ + var tupleNumber: TupleNumber? + get() = XyzMembers.XyzTn.getTupleNumber(this) + set(value) { + XyzMembers.XyzTn.set(this, value) + } /** - * The number of the collection, which is basically [featureNumber]. + * The database-number of the collection; the collection-feature itself is stored in the same database as the collection it describes. * @since 3.0 + * @throws NakshaException with error [ILLEGAL_STATE], when the collection does not have a valid [tupleNumber]. */ - val number: Int - get() = featureNumber.toInt() + val databaseNumber: Int64 + get() = tupleNumber?.databaseNumber ?: throw NakshaException(ILLEGAL_STATE, "The collection has no tuple-number") + + /** + * The database-id of the collection; the collection-feature itself is stored in the same database as the collection it describes. + * @since 3.0 + */ + var databaseId: String? by DATABASE_ID + + /** + * @see [databaseId] + */ + fun withDatabaseId(value: String): NakshaCollection { + val tn = tupleNumber + if (tn != null) { + if (Naksha.databaseNumber(value) != tn.databaseNumber) { + throw NakshaException(ILLEGAL_ARGUMENT, "The given database-id does not match the database-number of the collection.") + } + } + databaseId = value + return this + } /** - * Always return `0`, because all collections are always stored in `naksha~collections` collection. + * The catalog-number of the collection; the collection-feature itself is stored in the same catalog as the collection it describes. * @since 3.0 - * @see [Naksha.COLLECTIONS_COL] - * @see [Naksha.COLLECTIONS_COL_NUMBER] + * @throws NakshaException with error [ILLEGAL_STATE], when the collection does not have a valid [tupleNumber]. */ - override val collectionNumber: Int - get() = Naksha.COLLECTIONS_COL_NUMBER + val catalogNumber: Int + get() = tupleNumber?.catalogNumber ?: throw NakshaException(ILLEGAL_STATE, "The collection has no tuple-number") /** - * The map-id of the map in which the collection is located; `null` if not yet known. + * The custom identifier of the catalog in which the collection is located; the collection-feature itself is stored in the same catalog as the collection it describes. * @since 3.0 */ - var mapId by MAP_ID + var catalogId: String? by CATALOG_ID /** - * @see [mapId] + * @see [catalogId] */ - fun withMapId(value: String?): NakshaCollection { - mapId = value + fun withCatalogId(value: String): NakshaCollection { + catalogId = value + val tn = tupleNumber + if (tn != null) { + if (Naksha.catalogNumber(value) != tn.catalogNumber) { + throw NakshaException(ILLEGAL_ARGUMENT, "The given catalog-id does not match the catalog-number of the collection.") + } + } + databaseId = value return this } + /** + * The collection-number of the collection, this is actually the same as the feature-number. + * + * It is **NOT** the collection-number of this collection-feature, so where the collection-feature itself is stored, which has always the collection-number `0`, because all collection features are always stored in the collection `naksha~collections`. + * @since 3.0 + * @see [Naksha.collectionNumber] + */ + val collectionNumber: Int + get() = Naksha.collectionNumber(id) + /** * If partitions is given, then the collection is internally partitioned in the storage, optimised for large quantities of features. The default is no partitions; as a rule of thumb, add one more partition for every 10 to 20 million features expected. * @@ -186,55 +233,6 @@ open class NakshaCollection() : NakshaFeature() { return this } - /** - * If the `featureType`as returned by [NakshaFeature.featureType] equals to this value, then the [metadata feature-type][naksha.model.Metadata.ft] will be `null`, otherwise [metadata feature-type][naksha.model.Metadata.ft] is set to the [NakshaFeature.featureType]. - * - * ### Note - * The index on the [feature-type][naksha.model.Metadata.ft] is partial, features are only indexed when `ft` is not `null`, what is always the case, when it matches the [defaultFeatureType]. This is based upon the assumption, that in most cases all features within a collection do have the same feature-type. If this assumption holds true, and index would be a big waste, even when only a few features differ from the [defaultFeatureType], adding all values into the index would be a waste. So, this property is for the query planner to take advantage of this fact, when searching for feature-type. If the feature-type is the [defaultFeatureType], this means most of the time a full collection scan, so usage of the feature-type index is not helpful. - * @since 3.0 - */ - var defaultFeatureType by DEFAULT_FEATURE_TYPE - - /** - * @see [defaultFeatureType] - */ - open fun withDefaultFeatureType(value: String?): NakshaCollection { - removeRaw("defaultFeatureType") - return this - } - - /** - * The feature encoding to use for new rows in this collection. - * - * - If _null_, the [dataEncoding][NakshaMap.dataEncoding] of the containing [map][NakshaMap] is used; if that is also _null_, [Naksha.DEFAULT_DATA_ENCODING] is used. - * @since 3.0 - */ - var dataEncoding by DATA_ENCODING - - /** - * @see [dataEncoding] - */ - open fun withDataEncoding(value: DataEncoding): NakshaCollection { - this.dataEncoding = value - return this - } - - /** - * The identifier of the global dictionary to use, when encoding new rows. - * - * - If _null_, the storage will use whatever is best for the storage. - * @since 3.0 - */ - var encodeDict by STRING_NULL - - /** - * @see [encodeDict] - */ - open fun withEncodeDict(value: String?): NakshaCollection { - this.encodeDict = value - return this - } - /** * If [StoreMode.OFF] the storage will not retain historic states of features in this collection, which boosts performance in certain operations. */ @@ -319,7 +317,7 @@ open class NakshaCollection() : NakshaFeature() { * @since 3.0 */ open fun addMember(value: Member): NakshaCollection { - Naksha.verifyId(value.name) + NakshaIdType.INTERNAL_MEMBER.verify(value.name) var list = this.members if (list == null) { list = MemberList() @@ -327,26 +325,135 @@ open class NakshaCollection() : NakshaFeature() { } for (existing in list) { if (existing != null && existing.name == value.name) { - throw NakshaException(NakshaError.ILLEGAL_ARGUMENT, "Duplicate member name: '${value.name}'") + throw NakshaException(ILLEGAL_ARGUMENT, "Duplicate member name: '${value.name}'") } } if (list.size >= MemberList.MAX_MEMBERS) { - throw NakshaException(NakshaError.ILLEGAL_ARGUMENT, "Cannot add more than ${MemberList.MAX_MEMBERS} members to a collection") + throw NakshaException(ILLEGAL_ARGUMENT, "Cannot add more than ${MemberList.MAX_MEMBERS} members to a collection") } list.add(value) return this } + // TODO: Add a removeMember method. + + /** + * Initializes the [members] to the bare minimum, therefore [mandatory members][StandardMembers.MANDATORY]. + * + * The method should be called, if next to the minimal members additional proprietary members should be added. + * @since 3.0 + */ + fun withMinimalMembers(): NakshaCollection { + members = MemberList(StandardMembers.MANDATORY) + return this + } + + /** + * Initializes the [members] to the [standard XYZ members][XyzMembers.ALL]. + * + * The method should be called, if next to the standard XYZ members, additional proprietary members should be added. + * @since 3.0 + */ + fun withXyzMembers(): NakshaCollection { + members = MemberList(XyzMembers.ALL) + return this + } + + /** + * Returns the validated members list. + * + * If the member list is currently `null`, it creates it from [XyzMembers.ALL]. If the list does not contain the mandatory members, they will be added. + * @return the members list of this collection. + * @since 3.0 + * @throws NakshaException with [ILLEGAL_STATE] + */ + open fun useMembers(): MemberList { + var write = false + var list = this.members + if (list == null) { + list = MemberList(XyzMembers.ALL) + write = true + } + // Ensure that the list does not contain null or duplicates. + list.validate() + // Ensure that the mandatory members are in. + for (mandatory in StandardMembers.MANDATORY) { + val found: Member? = list.get(mandatory.name) + if (found != null) { + mandatory.asSame(found, comparePath = false) + } else { + list.add(mandatory) + write = true + } + } + if (write) this.members = list + return list + } + + /** + * Search for the given member in this collection. + * @param member The member to search for, compares name and type. + * @param comparePath If _true_, then the path is as well compared; otherwise any path is accepted. + * @return the found member. + * @throws NakshaException If no such member was found. + */ + @JvmOverloads + open fun useMember(member: Member, comparePath: Boolean = false): Member = member.asSame(useMembers().get(member.name), comparePath) + + /** + * Search for the given member in this collection. + * @param memberName the name of the member to use. + * @return the member. + * @throws NakshaException with error [ILLEGAL_STATE], if no such member was found. + */ + @JsName("useMemberByName") + open fun useMember(memberName: String): Member = useMembers().get(memberName) ?: throw NakshaException(ILLEGAL_STATE, "Member $memberName does not exist") + + /** + * Search for the given member in this collection. + * @param member The member to search for, compares name and type. + * @param comparePath If _true_, then the path is as well compared; otherwise any path is accepted. + * @return the found member or `null`, if no such member is declared in this collection. + */ + @JvmOverloads + open fun findMember(member: Member, comparePath: Boolean = false): Member? { + val found = useMembers().get(member.name) + if (member.isSameAs(found, comparePath)) return found + return null + } + /** * The indices to maintain on this collection. * - * Each [Index] declares a name, an [IndexType] ([IndexType.BTREE] / [IndexType.SPATIAL] / [IndexType.TAGS] / [IndexType.SET]), the column(s) to index, an optional include-list (for [IndexType.BTREE]), and a `unique` flag. + * Each [Index] declares a name, an [IndexType] ([IndexType.BTREE] / [IndexType.SPATIAL] / [IndexType.TAG_MAP] / [IndexType.TAG_LIST]), the column(s) to index, an optional include-list (for [IndexType.BTREE]), and a `unique` flag. * * Indices are applied to every variant of the collection (head, history, deleted, meta) by the storage. Mandatory and default indices are injected by the storage; clients only need to declare additional custom indices here. * @since 3.0 */ var indices: IndexList? by INDICES + /** + * Initializes the [indices] to the bare minimum. + * + * The method should be called, if next to the minimal indices additional proprietary indices should be added. + * @since 3.0 + */ + fun withMinimalIndices(): NakshaCollection { + indices = IndexList() + return this + } + + /** + * Initializes the [indices] to the [standard XYZ indices][XyzIndices.ALL]. + * + * The method should be called, if next to the standard XYZ indices, additional proprietary indices should be added. + * @since 3.0 + */ + fun withXyzIndices(): NakshaCollection { + indices = IndexList(XyzIndices.ALL) + return this + } + /** * @see [indices] */ @@ -379,7 +486,7 @@ open class NakshaCollection() : NakshaFeature() { * @since 3.0 */ open fun addIndex(value: Index): NakshaCollection { - Naksha.verifyId(value.name) + NakshaIdType.INDEX.verify(value.name) var list = this.indices if (list == null) { list = IndexList() @@ -553,14 +660,12 @@ open class NakshaCollection() : NakshaFeature() { @JsStatic val UNKNOWN = Int64(-1) + private val DATABASE_ID = NullableProperty(String::class) + private val CATALOG_ID = NullableProperty(String::class) private val PARTITIONS = NotNullProperty(Int::class) { _, _ -> 1 } private val SHIFT = NotNullProperty(Int::class) { _, _ -> 41 } private val STORAGE_CLASS = NullableProperty(String::class) private val PROTECTION_CLASS = NullableProperty(String::class) - private val DATA_ENCODING = NullableEnum(DataEncoding::class) - private val MAP_ID = NullableProperty(String::class) - private val STRING_NULL = NullableProperty(String::class) - private val DEFAULT_FEATURE_TYPE = NotNullProperty(String::class) { _, _ -> TYPE } private val MEMBERS = NullableProperty(MemberList::class) private val INDICES = NullableProperty(IndexList::class) private val MAX_AGE = NotNullProperty(Int64::class) { _, _ -> Int64(-1) } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaDictionary.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaDictionary.kt index 2c771b9866..4bedc09c71 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaDictionary.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaDictionary.kt @@ -37,7 +37,6 @@ open class NakshaDictionary() : NakshaFeature() { override fun featureTypeDefaultValue(): String = FEATURE_TYPE override fun withId(value: String): NakshaDictionary = super.withId(value) as NakshaDictionary - override fun withFeatureNumber(value: Int64): NakshaDictionary = super.withFeatureNumber(value) as NakshaDictionary override fun withType(value: String): NakshaDictionary = super.withType(value) as NakshaDictionary override fun withFeatureType(value: String): NakshaDictionary = super.withFeatureType(value) as NakshaDictionary override fun withBbox(value: SpBoundingBox?): NakshaDictionary = super.withBbox(value) as NakshaDictionary diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaFeature.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaFeature.kt index 0927981c69..8b9e258fc3 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaFeature.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaFeature.kt @@ -69,7 +69,6 @@ open class NakshaFeature() : AnyObject() { private val PROPERTIES = NotNullProperty(NakshaProperties::class) private val TITLE_NULL = NullableProperty(String::class) private val DESCRIPTION_NULL = NullableProperty(String::class) - private val ATTACHMENT_NULL = NullableProperty(ByteArray::class) } /** @@ -98,100 +97,6 @@ open class NakshaFeature() : AnyObject() { return this } - /** - * Returns the [tuple-number][TupleNumber] of this feature, may be [TupleNumber.HEAD], if the feature is not yet persisted. - * @since 3.0 - */ - val tupleNumber: TupleNumber - get() = properties.xyz.guid?.tupleNumber ?: TupleNumber.HEAD - - private var cachedId: String? = null - private var cachedFeatureNumber: Int64? = null - - /** - * Calculates the feature number from the given identifier. - * @param id the identifier. - * @return the feature-number. - */ - protected open fun featureNumberOfId(id: String): Int64 = Naksha.featureNumber(id) - - /** - * The feature-number of the feature. - * - * If the feature is in [HEAD][TupleNumber.HEAD] state, so not yet persisted, and no custom feature number was set, then the method will calculate the feature-number from the [id]. A custom feature-number - * @since 3.0 - */ - var featureNumber: Int64 - get() { - val id = this.id - val internalNumber = Naksha.internalIdToNumber[id] - if (internalNumber != null) return internalNumber.toInt64() - val cachedId = this.cachedId - var cachedFeatureNumber = this.cachedFeatureNumber - - // If the user changed the id. - if (id === cachedId && cachedFeatureNumber != null) return cachedFeatureNumber - - // If the feature exists already, and the `id` was not changed, return existing feature number. - val guid = properties.xyz.guid - if (tupleNumber != TupleNumber.HEAD && guid != null && id == guid.id) return guid.tupleNumber.featureNumber - - // Calculate a feature number for new feature. - cachedFeatureNumber = featureNumberOfId(id) - this.cachedId = id - this.cachedFeatureNumber = cachedFeatureNumber - return cachedFeatureNumber - } - set(value) { - if (value < 0) { - throw illegalArg( - "The given feature-number is invalid, must be positive 0 to 9,223,372,036,854,775,807, but was $value" - ) - } - // When the user sets the feature-number, this means the `id` becomes the feature-number too! - val newId = value.toString() - cachedFeatureNumber = featureNumberOfId(newId) - cachedId = newId - id = newId - } - - /** - * Sets a custom feature-number, which in fact changes the [id] of the feature, and must be a 63-bit unsigned integer (`0 .. 9,223,372,036,854,775,807`). - * @see [featureNumber] - */ - open fun withFeatureNumber(value: Int64): NakshaFeature { - this.featureNumber = value - return this - } - - /** - * The global unique identifier of the feature, exists only if the feature is already persisted in a storage. - * @since 3.0 - */ - val guid: Guid? - get() = properties.xyz.guid - - /** - * Returns the collection-number of the collection in which the feature is currently persisted; `null` if the feature is not yet persisted. - * @since 3.0 - */ - open val collectionNumber: Int? - get() = guid?.tupleNumber?.collectionNumber - - /** - * Returns the map-number of the map in which the feature is currently persisted; `null` if the feature is not yet persisted. - * @since 3.0 - */ - open val mapNumber: Int? - get() = guid?.tupleNumber?.mapNumber - - /** - * Returns the storage-number of the storage in which the feature is currently persisted; `null` if the feature is not yet persisted. - * @since 3.0 - */ - open val storageNumber: Int64? - get() = guid?.tupleNumber?.storageNumber - /** * The type of the feature, to be [GeoJSON](https://datatracker.ietf.org/doc/html/rfc7946) compatible, one of the following is expected: * - `FeatureCollection` diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaMap.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaMap.kt deleted file mode 100644 index b6b0ab844e..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaMap.kt +++ /dev/null @@ -1,104 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model.objects - -import naksha.base.* -import naksha.geo.SpBoundingBox -import naksha.geo.SpGeometry -import naksha.geo.SpPoint -import naksha.model.* -import kotlin.js.JsExport -import kotlin.js.JsName - -/** - * A map within a storage; maps are used to group collections. - * @since 3.0 - */ -@JsExport -open class NakshaMap() : NakshaFeature() { - - /** - * Create a new map feature with the given identifier. - * @param id the identifier to set. - * @since 3.0 - */ - @Suppress("LeakingThis") - @JsName("of") - constructor(id: String): this() { - this.id = id - this.type = typeDefaultValue() - this.featureType = featureTypeDefaultValue() - } - - companion object NakshaMap_C { - /** - * The feature-type of this feature itself _(`naksha.Map`)_. - * @since 3.0 - */ - const val FEATURE_TYPE = "naksha.Map" - - private val STORAGE_ID = NullableProperty(String::class) - private val DATA_ENCODING = NullableEnum(DataEncoding::class) - } - - override fun featureTypeDefaultValue(): String = FEATURE_TYPE - override fun withId(value: String): NakshaMap = super.withId(value) as NakshaMap - override fun withFeatureNumber(value: Int64): NakshaMap = super.withFeatureNumber(value) as NakshaMap - override fun withType(value: String): NakshaMap = super.withType(value) as NakshaMap - override fun withFeatureType(value: String): NakshaMap = super.withFeatureType(value) as NakshaMap - override fun withBbox(value: SpBoundingBox?): NakshaMap = super.withBbox(value) as NakshaMap - override fun withGeometry(value: SpGeometry?): NakshaMap = super.withGeometry(value) as NakshaMap - override fun withReferencePoint(value: SpPoint?): NakshaMap = super.withReferencePoint(value) as NakshaMap - override fun withProperties(value: NakshaProperties): NakshaMap = super.withProperties(value) as NakshaMap - override fun withMomType(value: String?): NakshaMap = super.withMomType(value) as NakshaMap - - /** - * The feature encoding to use for new rows of all collections of this map that do not have an own [dataEncoding][NakshaCollection.dataEncoding]. - * - * - If _null_, the storage will use [Naksha.DEFAULT_DATA_ENCODING]. - * @since 3.0 - */ - var dataEncoding by DATA_ENCODING - - override fun featureNumberOfId(id: String): Int64 = Naksha.mapNumber(id).toInt64() - - /** - * The number of the map, which is basically [featureNumber]. - * @since 3.0 - */ - val number: Int - get() = featureNumber.toInt() - - /** - * Always return `2`, because all catalogs (maps) are always stored in `naksha~catalogs` collection. - * @since 3.0 - * @see [Naksha.CATALOGS_COL] - * @see [Naksha.CATALOGS_COL_NUMBER] - */ - override val collectionNumber: Int - get() = Naksha.CATALOGS_COL_NUMBER - - /** - * Always return `0`, because all maps are always stored in `naksha~admin` map. - * @since 3.0 - * @see [Naksha.ADMIN_MAP] - * @see [Naksha.ADMIN_MAP_NUMBER] - */ - override val mapNumber: Int - get() = Naksha.ADMIN_MAP_NUMBER - - /** - * The storage-id of the storage in which the map is located; `null` if not yet known. - * @since 3.0 - */ - var storageId by STORAGE_ID - - /** - * @see [storageId] - */ - fun withStorageId(value: String?): NakshaMap { - storageId = value - return this - } - -} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaStorage.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaStorage.kt index ae7cbed1c6..a7156531d1 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaStorage.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaStorage.kt @@ -11,6 +11,7 @@ import naksha.model.Naksha import naksha.model.NakshaError import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT import naksha.model.NakshaException +import naksha.model.TupleNumber import kotlin.js.* import kotlin.jvm.JvmOverloads import kotlin.jvm.JvmStatic @@ -54,6 +55,7 @@ open class NakshaStorage() : NakshaFeature() { private val HARDCAP = NotNullProperty(Int::class) { _, _ -> 0 } private val CREATE = NotNullProperty(Boolean::class) { _, _ -> false } private val UPGRADE = NotNullProperty(Boolean::class) { _, _ -> false } + //private val DB_NUMBER = NotNullProperty(Int64::class) { self, _ -> Naksha.featureNumber(self.id) } /** * Helper class to parse a JSON configuration into a [NakshaStorage]. @@ -78,7 +80,6 @@ open class NakshaStorage() : NakshaFeature() { override fun featureTypeDefaultValue(): String = FEATURE_TYPE override fun withId(value: String): NakshaStorage = super.withId(value) as NakshaStorage - override fun withFeatureNumber(value: Int64): NakshaStorage = super.withFeatureNumber(value) as NakshaStorage override fun withType(value: String): NakshaStorage = super.withType(value) as NakshaStorage override fun withFeatureType(value: String): NakshaStorage = super.withFeatureType(value) as NakshaStorage override fun withBbox(value: SpBoundingBox?): NakshaStorage = super.withBbox(value) as NakshaStorage @@ -165,6 +166,17 @@ open class NakshaStorage() : NakshaFeature() { return this } + /** + * The database-number to which this storage is hard-wired _(until we support multi-databases per storage)_. + * @since 3.0 + */ + val databaseNumber: Int64 + get() { + // TODO: We need to allow a custom database number, actually we need to decouple the storage from the database. + // However, this is a much larger architectural change, so for now, the storage and the database are hard-wired the same! + return Naksha.featureNumber(id) + } + override fun equals(other: Any?): Boolean { if (other is NakshaStorage) return super.contentDeepEquals(other) return false @@ -178,15 +190,6 @@ open class NakshaStorage() : NakshaFeature() { hardCap.hashCode() } - override fun featureNumberOfId(id: String): Int64 = Naksha.storageNumber(id) - - /** - * The number of the storage, which is basically [featureNumber]. - * @since 3.0 - */ - val number: Int64 - get() = featureNumber - /** * Compares this configuration with another [NakshaStorage] instance. * diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTx.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTx.kt index 1b144ea3b9..0a1e3e8c29 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTx.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTx.kt @@ -22,7 +22,6 @@ open class NakshaTx : NakshaFeature() { override fun featureTypeDefaultValue(): String = "naksha.Tx" override fun withId(value: String): NakshaTx = super.withId(value) as NakshaTx - override fun withFeatureNumber(value: Int64): NakshaTx = super.withFeatureNumber(value) as NakshaTx override fun withType(value: String): NakshaTx = super.withType(value) as NakshaTx override fun withFeatureType(value: String): NakshaTx = super.withFeatureType(value) as NakshaTx override fun withBbox(value: SpBoundingBox?): NakshaTx = super.withBbox(value) as NakshaTx @@ -46,7 +45,7 @@ open class NakshaTx : NakshaFeature() { */ @JvmOverloads fun setEpoch(epoch: Timestamp, seq: Int64 = Int64(0)): NakshaTx { - val version = Version.auto(epoch.year, epoch.month, epoch.day, seq) + val version = Version.auto(epoch.year, epoch.month, epoch.day, seq, Action.VERSION) setRaw("id", version.toString()) setRaw("time", epoch.ts) return this @@ -96,7 +95,7 @@ open class NakshaTx : NakshaFeature() { override var id: String get() = getAs("id", String::class) ?: throw illegalState("The property 'id' must be a valid string") set(value) { - val txn = Int64(value.toLong()) + val txn = Int64(value.toLong(10)) setVersion(Version(txn)) } @@ -155,7 +154,7 @@ open class NakshaTx : NakshaFeature() { * @since 3.0 */ val txn: Int64 - get() = version.txn + get() = version.number /** * Number of features modified in the transaction - total number of features from all touched collections. @@ -200,7 +199,7 @@ open class NakshaTx : NakshaFeature() { * @since 3.0 */ @JvmOverloads - fun useMap(id: String, number: Int, action: Action? = null): NakshaTxMap { + fun useCatalog(id: String, number: Int, action: Action? = null): NakshaTxMap { val existing = maps[id] if (existing != null) { if (action != null) existing.action = action diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTxCollection.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTxCollection.kt index 799ff3f4b7..fe384e9a08 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTxCollection.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/NakshaTxCollection.kt @@ -6,9 +6,9 @@ import naksha.base.NotNullProperty import naksha.base.AnyObject import naksha.base.NullableProperty import naksha.model.Action -import naksha.model.Action.Action_C.CREATED -import naksha.model.Action.Action_C.DELETED -import naksha.model.Action.Action_C.UPDATED +import naksha.model.Action.Action_C.CREATE +import naksha.model.Action.Action_C.DELETE +import naksha.model.Action.Action_C.UPDATE import naksha.model.NakshaError import naksha.model.NakshaException import naksha.model.TupleNumber @@ -107,9 +107,9 @@ class NakshaTxCollection() : AnyObject() { */ fun add(tupleNumber: TupleNumber, partitions: Int? = null): NakshaTxCollection { when (tupleNumber.action) { - CREATED -> this.created += 1 - UPDATED -> this.updated += 1 - DELETED -> this.deleted += 1 + CREATE -> this.created += 1 + UPDATE -> this.updated += 1 + DELETE -> this.deleted += 1 } if (partitions != null && partitions > 1) { var featuresByPartition = this.featuresByPartition diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/SpatialMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/SpatialMember.kt new file mode 100644 index 0000000000..3b0afd3e77 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/SpatialMember.kt @@ -0,0 +1,48 @@ +package naksha.model.objects + +import naksha.geo.SpGeometry +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.SPATIAL +import kotlin.js.JsName + +class SpatialMember() : TypedMember() { + override fun verify(): SpatialMember { + if (dataType != SPATIAL) { + throw illegalState("The member was illegally cast, expected subtype: $SPATIAL, found: $dataType") + } + return this + } + + /** Creates a new spatial member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = SPATIAL + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a spatial member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != SPATIAL) throw illegalArg("The given member is not of spatial type") + this.name = member.name + this.dataType = SPATIAL + this.path = path?.validate() ?: member.path + } + + /** Retrieves the spatial geometry value of this member from the given feature. */ + fun get(feature: NakshaFeature): SpGeometry? = getGeometry(feature) + + /** + * Retrieves the spatial geometry value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): SpGeometry? = getGeometry(tuple) + + /** Sets the spatial geometry value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: SpGeometry): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardIndices.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardIndices.kt index cabe9dce1a..b606c15cc2 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardIndices.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardIndices.kt @@ -5,47 +5,14 @@ package naksha.model.objects import kotlin.js.JsExport import kotlin.js.JsStatic import kotlin.jvm.JvmField -import kotlin.jvm.JvmStatic /** - * The canonical set of standard indices that every Naksha storage understands. - * - * Indices are divided into two groups: - * - * ### Mandatory indices - * These are always created by the storage regardless of the [NakshaCollection.indices] list. - * Clients must not declare them manually. They are marked `internal = true` in the storage layer. - * - * - [StandardIndices_C.FeatureNumberUnique] — PRIMARY KEY on `fn` (all tables) - * - [StandardIndices_C.IdUnique] — UNIQUE on `id` WHERE `id IS NOT NULL` (HEAD / DELETED / META tables) - * - [StandardIndices_C.Id] — non-unique index on `id`, `fn`, `version` WHERE `id IS NOT NULL` (HISTORY tables) - * - [StandardIndices_C.Version] — non-unique index on `version` (all tables) - * - [StandardIndices_C.GlobalBookNumber] — conditional non-unique index on `gbn` WHERE `gbn IS NOT NULL` (all tables) - * - * ### Special indices - * These are **not** created by default. They must be explicitly declared in - * [NakshaCollection.indices] and are defined here so that all storage implementations share - * a consistent name and type contract. - * - * - [StandardIndices_C.PublishNumber] — BTREE on `pn` (WHERE `pn IS NOT NULL`) - * - [StandardIndices_C.PublishTime] — BTREE on `pt` (WHERE `pt IS NOT NULL`) - * - [StandardIndices_C.GlobalVersion] — BTREE on `gv` (WHERE `gv IS NOT NULL`) - * - * ### Default indices - * These are created automatically when [NakshaCollection.indices] is `null` (backward-compatible - * full schema). When [NakshaCollection.indices] is explicitly set (even to an empty list), only - * the mandatory indices plus the explicitly declared indices are created. - * - * - [StandardIndices_C.HereTile] — `here_tile`, `fn`, `version` - * - [StandardIndices_C.AppId] — `app_id`, `updated_at`, `fn`, `version` - * - [StandardIndices_C.Author] — `author`, `author_ts`, `fn`, `version` - * - [StandardIndices_C.Tags] — GIN set index over the tags - * - [StandardIndices_C.FeatureType] — `ft`, `fn`, `version` - * - [StandardIndices_C.CustomValue0] .. [StandardIndices_C.CustomValue3] — custom numeric values - * - [StandardIndices_C.CustomString0] .. [StandardIndices_C.CustomString3] — custom string values - * - [StandardIndices_C.ReferencePoint] — SP-GIST reference-point geometry - * - [StandardIndices_C.GistGeometry] — GIST geometry + * The canonical, storage-managed indices that every Naksha storage understands. * + * These are flavour-independent: the [MANDATORY] indices are always present, the standard optional + * [Geometry] indexes the standard [StandardMembers.Geometry] member, and the [SPECIAL] indices + * are declared explicitly per collection (e.g. `naksha~transactions`). The default index set for a + * Data-Hub (XYZ) compatible collection lives in [XyzIndices], the index counterpart of [XyzMembers]. * @since 3.0 */ @JsExport @@ -62,7 +29,7 @@ class StandardIndices private constructor() { * @since 3.0 */ @JvmField @JsStatic - val FeatureNumberUnique = Index("fn_unique", IndexType.BTREE, "fn").withInternal(true) + val FeatureNumberUnique = Index("fn_unique", "fn").withInternal(true) /** * `id_unique` — UNIQUE index on `id` (WHERE `id IS NOT NULL`). Present in HEAD, DELETED, and @@ -70,7 +37,7 @@ class StandardIndices private constructor() { * @since 3.0 */ @JvmField @JsStatic - val IdUnique = Index("id_unique", IndexType.BTREE, "id").withInternal(true).withUnique(true) + val IdUnique = Index("id_unique", "id").withInternal(true).withUnique(true) /** * `id` — non-unique index on `id`, `fn`, `version` (WHERE `id IS NOT NULL`). Present in @@ -78,14 +45,14 @@ class StandardIndices private constructor() { * @since 3.0 */ @JvmField @JsStatic - val Id = Index("id", IndexType.BTREE, "id", "fn", "version").withInternal(true) + val Id = Index("id", "id", "fn", "version").withInternal(true) /** * `version` — non-unique index on `version`. Present in all tables. Mandatory. * @since 3.0 */ @JvmField @JsStatic - val Version = Index("version", IndexType.BTREE, "version").withInternal(true) + val Version = Index("version", "version").withInternal(true) /** * `gbn` — conditional non-unique index on `gbn` WHERE `gbn IS NOT NULL`. Used by the sequencer @@ -94,11 +61,10 @@ class StandardIndices private constructor() { * @since 3.0 */ @JvmField @JsStatic - val GlobalBookNumber = Index("gbn", IndexType.BTREE, "gbn").withInternal(true) + val GlobalBookNumber = Index("gbn", "gbn").withInternal(true) /** - * All mandatory indices, in declaration order. These are always created by the storage and must - * not appear in [NakshaCollection.indices]. + * All mandatory indices, in declaration order. These are always created by the storage. * @since 3.0 */ @JvmField @JsStatic @@ -112,173 +78,46 @@ class StandardIndices private constructor() { val MANDATORY_NAMES: Set = MANDATORY.map { it.name }.toHashSet() // ------------------------------------------------------------------------- - // Special indices — not in MANDATORY or DEFAULT; declared explicitly per collection + // Standard optional indices — index a standard optional member (see StandardMembers). // ------------------------------------------------------------------------- /** - * `pn` — BTREE index on `pn` (WHERE `pn IS NOT NULL`). Enables efficient ordering and - * range scans by publish-number. Used in `naksha~transactions`. - * @since 3.0 - */ - @JvmField @JsStatic - val PublishNumber = Index("pn", IndexType.BTREE, "pn") - - /** - * `pt` — BTREE index on `pt` (WHERE `pt IS NOT NULL`). Enables efficient range scans - * by publish-time. Used in `naksha~transactions`. - * @since 3.0 - */ - @JvmField @JsStatic - val PublishTime = Index("pt", IndexType.BTREE, "pt") - - /** - * `gv` — BTREE index on `gv` (WHERE `gv IS NOT NULL`). Enables efficient range scans - * by HERE global version. Used in `naksha~transactions`. + * `geo` — spatial index over the geometry member. + * + * Geometry is a **standard** member (part of the GeoJSON standard, see [StandardMembers.Geometry]). * @since 3.0 */ @JvmField @JsStatic - val GlobalVersion = Index("gv", IndexType.BTREE, "gv") + val Geometry = Index("geo", "geo") // ------------------------------------------------------------------------- - // Default indices — created when NakshaCollection.indices is null + // Special indices — not added automatically; declared explicitly per collection + // (e.g. naksha~transactions). The default XYZ index set lives in XyzIndices. // ------------------------------------------------------------------------- /** - * `here_tile` — index on `here_tile`, `fn`, `version` (WHERE `here_tile IS NOT NULL`). - * Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val HereTile = Index("here_tile", IndexType.BTREE, "here_tile", "fn", "version") - - /** - * `app_id` — index on `app_id`, `updated_at`, `fn`, `version` (WHERE `app_id IS NOT NULL`). - * Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val AppId = Index("app_id", IndexType.BTREE, "app_id", "updated_at", "fn", "version") - - /** - * `author` — index on the effective author and author timestamp, `fn`, `version` - * (WHERE effective author IS NOT NULL). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val Author = Index("author", IndexType.BTREE, "author", "author_ts", "fn", "version") - - /** - * `tags` — inverted ([IndexType.SET]) index over the `tags` member, supporting element - * containment queries. Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val Tags = Index("tags", IndexType.SET, "tags") - - /** - * `feature_type` — index on `ft`, `fn`, `version` (WHERE `ft IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val FeatureType = Index("feature_type", IndexType.BTREE, "ft", "fn", "version") - - /** - * `cv0` — index on custom numeric value 0, `fn`, `version` (WHERE `cv0 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue0 = Index("cv0", IndexType.BTREE, "cv0", "fn", "version") - - /** - * `cv1` — index on custom numeric value 1, `fn`, `version` (WHERE `cv1 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue1 = Index("cv1", IndexType.BTREE, "cv1", "fn", "version") - - /** - * `cv2` — index on custom numeric value 2, `fn`, `version` (WHERE `cv2 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue2 = Index("cv2", IndexType.BTREE, "cv2", "fn", "version") - - /** - * `cv3` — index on custom numeric value 3, `fn`, `version` (WHERE `cv3 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue3 = Index("cv3", IndexType.BTREE, "cv3", "fn", "version") - - /** - * `cs0` — index on custom string value 0, `fn`, `version` (WHERE `cs0 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString0 = Index("cs0", IndexType.BTREE, "cs0", "fn", "version") - - /** - * `cs1` — index on custom string value 1, `fn`, `version` (WHERE `cs1 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString1 = Index("cs1", IndexType.BTREE, "cs1", "fn", "version") - - /** - * `cs2` — index on custom string value 2, `fn`, `version` (WHERE `cs2 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString2 = Index("cs2", IndexType.BTREE, "cs2", "fn", "version") - - /** - * `cs3` — index on custom string value 3, `fn`, `version` (WHERE `cs3 IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString3 = Index("cs3", IndexType.BTREE, "cs3", "fn", "version") - - /** - * `ref_point` — spatial ([IndexType.SPATIAL]) index over the reference-point geometry member - * (WHERE `ref_point IS NOT NULL`). Default index. - * @since 3.0 - */ - @JvmField @JsStatic - val ReferencePoint = Index("ref_point", IndexType.SPATIAL, "ref_point") - - /** - * `gist_geo` — spatial ([IndexType.SPATIAL]) GIST index over the geometry member - * (WHERE `geo IS NOT NULL`). Default index. + * `pn` — BTREE index on `pn` (WHERE `pn IS NOT NULL`). Enables efficient ordering and + * range scans by publish-number. Used in `naksha~transactions`. * @since 3.0 */ @JvmField @JsStatic - val GistGeometry = Index("gist_geo", IndexType.SPATIAL, "geo") + val PublishNumber = Index("pn", "pn") /** - * All default indices (created when [NakshaCollection.indices] is `null`), in declaration order. - * - * Does **not** include the [MANDATORY] indices — those are always present regardless. + * `pt` — BTREE index on `pt` (WHERE `pt IS NOT NULL`). Enables efficient range scans + * by publish-time. Used in `naksha~transactions`. * @since 3.0 */ @JvmField @JsStatic - val DEFAULT: List = listOf( - HereTile, - AppId, - Author, - Tags, - FeatureType, - CustomValue0, CustomValue1, CustomValue2, CustomValue3, - CustomString0, CustomString1, CustomString2, CustomString3, - ReferencePoint, - GistGeometry, - ) + val PublishTime = Index("pt", "pt") /** - * The names of all [DEFAULT] indices, for fast lookup. + * `gv` — BTREE index on `gv` (WHERE `gv IS NOT NULL`). Enables efficient range scans + * by HERE global version. Used in `naksha~transactions`. * @since 3.0 */ @JvmField @JsStatic - val DEFAULT_NAMES: Set = DEFAULT.map { it.name }.toHashSet() + val GlobalVersion = Index("gv", "gv") /** * All special indices — not added automatically but recognised by all storage implementations. @@ -293,19 +132,5 @@ class StandardIndices private constructor() { */ @JvmField @JsStatic val SPECIAL_NAMES: Set = SPECIAL.map { it.name }.toHashSet() - - /** - * All standard indices: [MANDATORY] followed by [DEFAULT] followed by [SPECIAL]. - * @since 3.0 - */ - @JvmField @JsStatic - val ALL: List = MANDATORY + DEFAULT + SPECIAL - - /** - * The names of all standard indices, for fast lookup. - * @since 3.0 - */ - @JvmField @JsStatic - val ALL_NAMES: Set = ALL.map { it.name }.toHashSet() } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardMembers.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardMembers.kt index a525414e4d..45b576f03d 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardMembers.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StandardMembers.kt @@ -5,60 +5,13 @@ package naksha.model.objects import kotlin.js.JsExport import kotlin.js.JsStatic import kotlin.jvm.JvmField -import kotlin.jvm.JvmStatic + +// TODO: We need immutability for the standard members and other pre-declared members. +// If not, we could encounter a situation where someone accidentally modified a standard member. /** * The canonical set of standard members that every Naksha storage understands. - * - * Members are divided into three groups: - * - * ### Mandatory members - * These are always managed and persisted by the storage, regardless of whether they appear in - * [NakshaCollection.members]. Clients must not declare them with a different [MemberType]. - * Their [Member.map] is always `null` — the storage resolves their values internally. - * - * - [StandardMembers_C.DatabaseNumber] — database-number (storage-level identifier) - * - [StandardMembers_C.CatalogNumber] — catalog-number (map-level identifier) - * - [StandardMembers_C.CollectionNumber] — collection-number (collection-level identifier) - * - [StandardMembers_C.FeatureNumber] — feature-number (per-collection feature identifier, routing key) - * - [StandardMembers_C.Version] — version of the tuple (with action bits in the lower 2 bits) - * - [StandardMembers_C.NextVersion] — version at which this state was superseded (history only) - * - [StandardMembers_C.Feature] — the serialised feature blob - * - [StandardMembers_C.GlobalBookNumber] — global-book-number for JBON2 global-book decoding (sequencer-assigned) - * - * ### Special members - * These members are **not** part of the default schema. They must be explicitly declared in - * [NakshaCollection.members] to be materialised. They are defined here so that all storage - * implementations share a consistent name and type contract. - * - * - [StandardMembers_C.PublishNumber] — publisher-assigned sequential visibility number (`pn`, INT64) - * - [StandardMembers_C.PublishTime] — epoch-millis timestamp when the publisher sequenced the transaction (`pt`, INT64) - * - [StandardMembers_C.GlobalVersion] — HERE global version number (`gv`, INT64) - * - * ### Default members - * These are included automatically when [NakshaCollection.members] is `null` (backward-compatible - * full schema). When [NakshaCollection.members] is explicitly set (even to an empty list), only - * the mandatory members plus the explicitly declared members are materialised. - * - * - [StandardMembers_C.UpdatedAt], [StandardMembers_C.CreatedAt], [StandardMembers_C.AuthorTimestamp] — timestamps - * - [StandardMembers_C.CustomValue0], [StandardMembers_C.CustomValue1], [StandardMembers_C.CustomValue2], [StandardMembers_C.CustomValue3] — custom numeric values - * - [StandardMembers_C.Hash] — content hash - * - [StandardMembers_C.HereTile] — HERE tile key of the reference point - * - [StandardMembers_C.ChangeCount] — change-count - * - [StandardMembers_C.BaseTupleNumber] — base tuple-number (three-way merge support) - * - [StandardMembers_C.Id] — feature string identifier (only for named features where `fn < 0`) - * - [StandardMembers_C.AppId] — application identifier - * - [StandardMembers_C.Author] — author identifier - * - [StandardMembers_C.Origin] — origin reference (forking / rebase support) - * - [StandardMembers_C.Target] — join target reference - * - [StandardMembers_C.FeatureType] — feature-type (only stored when it differs from the collection default) - * - [StandardMembers_C.CustomString0], [StandardMembers_C.CustomString1], [StandardMembers_C.CustomString2], [StandardMembers_C.CustomString3] — custom string values - * - [StandardMembers_C.Tags] — feature tags (set of unique strings, order preserved) - * - [StandardMembers_C.ReferencePoint] — geometry reference point (TWKB, [MemberType.SPATIAL]) - * - [StandardMembers_C.Geometry] — feature geometry (TWKB, [MemberType.SPATIAL]) - * - [StandardMembers_C.Attachment] — arbitrary binary attachment - * - * @since 3.0 + * @since 3.0 */ @JsExport class StandardMembers private constructor() { @@ -66,91 +19,64 @@ class StandardMembers private constructor() { companion object StandardMembers_C { // ------------------------------------------------------------------------- - // Mandatory members — storage-managed, always present, no JSON path + // Mandatory members — storage-managed, always present. // ------------------------------------------------------------------------- /** - * `dbn` — **Database-number** (`INT64`). The storage-level numeric identifier of the storage - * instance. Mandatory, storage-managed. Not materialised as a physical column in all - * implementations (e.g. `lib-psql` treats it as implicit context). - * @since 3.0 - */ - @JvmField @JsStatic - val DatabaseNumber = Member("dbn", MemberType.INT64) - - /** - * `catn` — **Catalog-number** (`INT32`). The map-level numeric identifier. Mandatory, - * storage-managed. Not materialised as a physical column in all implementations - * (e.g. `lib-psql` treats it as implicit schema context). - * @since 3.0 - */ - @JvmField @JsStatic - val CatalogNumber = Member("catn", MemberType.INT32) - - /** - * `coln` — **Collection-number** (`INT32`). The collection-level numeric identifier. Mandatory, - * storage-managed. Not materialised as a physical column in all implementations - * (e.g. `lib-psql` treats it as implicit context). + * `_tn` — **Tuple-Number**. The [naksha.model.TupleNumber] of this feature. This persists out of: + * - `database-number: long` - The database in which the feature is stored. + * - `catalog-number: int` - The catalog in which the feature is stored. + * - `collection-number: int` - The collection in which the feature is stored. + * - `feature-number: long` - The unique identifier of the feature within the collection. + * - `version: long` - The version of this feature state. + * + * **Beware**: Not all storages will persist the full tuple-number, i.e. `lib-psql` will only store the `feature-number` as `_fn` and the `version` as `_version`, because the other parts can be deducted from the table location. Searching for parts of the tuple-number is supported by the [ReadFeatures][naksha.model.request.ReadFeatures] request using the [version][naksha.model.request.ReadFeatures.version] property. * @since 3.0 */ @JvmField @JsStatic - val CollectionNumber = Member("coln", MemberType.INT32) + val Tn = TupleNumberMember("_tn", JsonPath("tn")).withMandatory() /** - * `fn` — **Feature-number** (`INT64`). The per-collection identifier of the feature. Together - * with [Version] this uniquely identifies a tuple. The lower 16 bits are used as the partition - * routing key. Mandatory, storage-managed. + * Virtual member that refers to the feature-number of the [tuple-number][Tn], the only purpose is for queries and result ordering. * @since 3.0 */ @JvmField @JsStatic - val FeatureNumber = Member("fn", MemberType.INT64) + val FeatureNumber = Int64Member("_fn", null).withMandatory().withVirtual() /** - * `version` — **Version** (`INT64`). The transaction number of the tuple, with the action - * encoded in the lower 2 bits. Together with [FeatureNumber] this uniquely identifies a tuple. - * Mandatory, storage-managed. + * Virtual member that refers to the version of the [tuple-number][Tn], the only purpose is for queries and result ordering. * @since 3.0 */ @JvmField @JsStatic - val Version = Member("version", MemberType.INT64) + val Version = Int64Member("_version", null).withMandatory().withVirtual() /** - * `next_version` — **Next-version** (`INT64`). The version at which this tuple was superseded - * by the next state. Present only in history; in head the value is intrinsically the current - * HEAD sentinel and is not stored as a physical member. Mandatory, storage-managed. + * `_nv` — **next-version** (`INT64`). The version at which this tuple was superseded by the next state. Present only in _HISTORY_; in _HEAD_ the value is intrinsically the current [HEAD version][naksha.model.Version.HEAD]. * @since 3.0 */ @JvmField @JsStatic - val NextVersion = Member("next_version", MemberType.INT64) + val NextVersion = Int64Member("_nv", JsonPath("nv")).withMandatory() - /** - * `feature` — **Serialised feature** (`BYTE_ARRAY`). The encoded feature blob. The encoding - * is controlled by [NakshaCollection.dataEncoding]. Mandatory, storage-managed. + /** + * `_global_book_fn` — **Global-book-feature-number** (`INT64`). References the feature-number in `naksha~books`, which carries the JBON2 global dictionary needed to decode this tuple's [Feature] blob when global-book encoding is used. * @since 3.0 */ @JvmField @JsStatic - val Feature = Member("feature", MemberType.BYTE_ARRAY) + val GlobalBookFeatureNumber = Int64Member("_global_book_fn", JsonPath("gbfn")).withMandatory() /** - * `id` — feature string identifier. Either stringified [FeatureNumber] _(as positive decimal number, i.e. `12345678`)_ or the unique identifier of the feature, resulting in a negative [FeatureNumber], generated by hashing the `id`. + * `_id` — custom feature string identifier. Either stringified feature-number _(as positive decimal number, i.e. `12345678`)_ or the unique identifier of the feature, resulting in a negative feature-number, generated by hashing the `id`. Technically, the storage can store `null`, when the feature-number is positive. * @since 3.0 */ @JvmField @JsStatic - val Id = Member("id", MemberType.STRING) + val Id = StringMember("_id", JsonPath("id")).withMandatory() /** - * `gbn` — **Global-book-number** (`INT64`). References the `global_book_fn` in - * `naksha~books`, which carries the JBON2 global dictionary needed to decode this tuple's - * [Feature] blob when global-book encoding is used. Assigned by the sequencer; `null` when - * no global book is in use. Mandatory, storage-managed (but physically `null` until the - * sequencer populates it). - * - * A conditional non-unique index on this member is always created so that the sequencer can - * efficiently locate all tuples that reference a particular book. + * `_feature` — **Serialised feature** (`BYTE_ARRAY`). * @since 3.0 */ @JvmField @JsStatic - val GlobalBookNumber = Member("gbn", MemberType.INT64) + val Feature = ByteArrayMember("_feature", JsonPath()).withMandatory() /** * All mandatory members, in declaration order. @@ -161,18 +87,15 @@ class StandardMembers private constructor() { * @since 3.0 */ @JvmField @JsStatic - val MANDATORY: List = listOf( - DatabaseNumber, CatalogNumber, CollectionNumber, FeatureNumber, - Version, NextVersion, Feature, Id, GlobalBookNumber - ) + val MANDATORY: List = listOf(Tn, NextVersion, Feature, Id, GlobalBookFeatureNumber) // ------------------------------------------------------------------------- - // Special members — not in MANDATORY or DEFAULT; declared explicitly per collection + // Optional members. // ------------------------------------------------------------------------- /** * `pn` — **Publish-number** (`INT64`). A sequencer-assigned, gap-free sequential number - * ordered by **visibility** rather than execution order. Unlike [Version], which can have + * ordered by **visibility** rather than execution order. Unlike `version`, which can have * holes (rolled-back transactions) and allows concurrent transactions to commit out of * order (e.g. version 3 commits before version 2), the publish-number is a strict * monotonically increasing sequence with no gaps: after `5` comes `6`, guaranteed. @@ -185,7 +108,7 @@ class StandardMembers private constructor() { * @since 3.0 */ @JvmField @JsStatic - val PublishNumber = Member("pn", MemberType.INT64) + val PublishNumber = Int64Member("pn") /** * `pt` — **Publish-time** (`INT64`). The millisecond epoch timestamp at which the @@ -196,7 +119,7 @@ class StandardMembers private constructor() { * @since 3.0 */ @JvmField @JsStatic - val PublishTime = Member("pt", MemberType.INT64) + val PublishTime = Int64Member("pt") /** * `gv` — **Global-version** (`INT64`). The HERE-internal global version number, @@ -207,180 +130,7 @@ class StandardMembers private constructor() { * @since 3.0 */ @JvmField @JsStatic - val GlobalVersion = Member("gv", MemberType.INT64) - - // ------------------------------------------------------------------------- - // Default members — included when NakshaCollection.members is null - // ------------------------------------------------------------------------- - - /** - * `updated_at` — millisecond epoch timestamp of the last modification. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val UpdatedAt = Member("updated_at", MemberType.INT64) - - /** - * `created_at` — millisecond epoch timestamp of the initial creation. `null` means the - * timestamp equals [UpdatedAt] (first-write optimisation). Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CreatedAt = Member("created_at", MemberType.INT64) - - /** - * `author_ts` — millisecond epoch timestamp of the last author change. `null` means the - * timestamp equals [UpdatedAt]. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val AuthorTimestamp = Member("author_ts", MemberType.INT64) - - /** - * `cv0` — custom numeric value 0 (`FLOAT64`). `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue0 = Member("cv0", MemberType.FLOAT64) - - /** - * `cv1` — custom numeric value 1 (`FLOAT64`). `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue1 = Member("cv1", MemberType.FLOAT64) - - /** - * `cv2` — custom numeric value 2 (`FLOAT64`). `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue2 = Member("cv2", MemberType.FLOAT64) - - /** - * `cv3` — custom numeric value 3 (`FLOAT64`). `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomValue3 = Member("cv3", MemberType.FLOAT64) - - /** - * `hash` — content hash of the tuple, computed by the storage. `null` if not recorded. - * Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val Hash = Member("hash", MemberType.INT32) - - /** - * `here_tile` — HERE tile key (binary) of the reference point. `null` if not known. - * Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val HereTile = Member("here_tile", MemberType.INT32) - - /** - * `cc` — change-count: how many times this feature has been modified. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val ChangeCount = Member("cc", MemberType.INT32) - - /** - * `base_tn` — base tuple-number (`BYTE_ARRAY`), set when a three-way merge was performed. - * `null` otherwise. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val BaseTupleNumber = Member("base_tn", MemberType.BYTE_ARRAY) - - /** - * `app_id` — identifier of the application that wrote this tuple. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val AppId = Member("app_id", MemberType.STRING) - - /** - * `author` — identifier of the human author that takes ownership for this tuple. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val Author = Member("author", MemberType.STRING) - - /** - * `origin` — stringified reference to the originating feature when this feature was forked or - * copied from another storage, map, or collection. Used for rebase support. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val Origin = Member("origin", MemberType.STRING) - - /** - * `target` — stringified reference to the feature into which this feature was joined. - * Set when multiple features are merged into one. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val Target = Member("target", MemberType.STRING) - - /** - * `ft` — feature-type string. `null` when it matches the collection's - * [default feature type][NakshaCollection.defaultFeatureType], avoiding redundant storage. - * Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val FeatureType = Member("ft", MemberType.STRING) - - /** - * `cs0` — custom string value 0. `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString0 = Member("cs0", MemberType.STRING) - - /** - * `cs1` — custom string value 1. `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString1 = Member("cs1", MemberType.STRING) - - /** - * `cs2` — custom string value 2. `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString2 = Member("cs2", MemberType.STRING) - - /** - * `cs3` — custom string value 3. `null` if not used. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val CustomString3 = Member("cs3", MemberType.STRING) - - /** - * `tags` — feature tags, the classic XYZ tags array located at - * `properties -> @ns:com:here:xyz -> tags` (e.g. `["foo", "bar"]`), stored as a - * [set][MemberType.SET] of unique strings. The array is persisted unmodified, so the - * element order is preserved when reading the feature back. `null` if the feature has no - * tags. Supports element containment queries via [IndexType.SET]. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val Tags = Member("tags", MemberType.SET) - - /** - * `ref_point` — geometry reference point (always a single point), stored as TWKB. Used to - * compute the [HereTile] value. `null` if the feature has no explicit reference point. - * Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val ReferencePoint = Member("ref_point", MemberType.SPATIAL) + val GlobalVersion = Int64Member("gv") /** * `geo` — feature geometry stored as TWKB. `null` if the feature has no geometry. @@ -388,79 +138,13 @@ class StandardMembers private constructor() { * @since 3.0 */ @JvmField @JsStatic - val Geometry = Member("geo", MemberType.SPATIAL) - - /** - * `attachment` — arbitrary binary attachment. `null` if unused. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val Attachment = Member("attachment", MemberType.BYTE_ARRAY) - - /** - * `data_encoding` — feature data encoding string. `null` if using default. Default member. - * @since 3.0 - */ - @JvmField @JsStatic - val DataEncoding = Member("data_encoding", MemberType.STRING) - - /** - * The names of all [MANDATORY] members, for fast lookup. - * @since 3.0 - */ - @JvmField @JsStatic - val MANDATORY_NAMES: Set = MANDATORY.map { it.name }.toHashSet() - - /** - * All default members (included when [NakshaCollection.members] is `null`), in declaration order. - * - * Does **not** include the [MANDATORY] members — those are always present regardless. - * @since 3.0 - */ - @JvmField @JsStatic - val DEFAULT: List = listOf( - UpdatedAt, CreatedAt, AuthorTimestamp, - CustomValue0, CustomValue1, CustomValue2, CustomValue3, - Hash, HereTile, ChangeCount, - BaseTupleNumber, - AppId, Author, Origin, Target, FeatureType, - CustomString0, CustomString1, CustomString2, CustomString3, - Tags, ReferencePoint, Geometry, Attachment, DataEncoding - ) - - /** - * The names of all [DEFAULT] members, for fast lookup. - * @since 3.0 - */ - @JvmField @JsStatic - val DEFAULT_NAMES: Set = DEFAULT.map { it.name }.toHashSet() - - /** - * All special members — not added automatically but recognised by all storage implementations. - * @since 3.0 - */ - @JvmField @JsStatic - val SPECIAL: List = listOf(PublishNumber, PublishTime, GlobalVersion) - - /** - * The names of all [SPECIAL] members, for fast lookup. - * @since 3.0 - */ - @JvmField @JsStatic - val SPECIAL_NAMES: Set = SPECIAL.map { it.name }.toHashSet() - - /** - * All standard members: [MANDATORY] followed by [DEFAULT] followed by [SPECIAL]. - * @since 3.0 - */ - @JvmField @JsStatic - val ALL: List = MANDATORY + DEFAULT + SPECIAL + val Geometry = SpatialMember("geo", JsonPath("geometry")) /** - * The names of all standard members, for fast lookup. + * `cc` — change-count: how many times this feature has been modified. There is some special low-level treatment for this member, if it is added to a collection. It is always initialized with `1` and incremented for every new state. * @since 3.0 */ @JvmField @JsStatic - val ALL_NAMES: Set = ALL.map { it.name }.toHashSet() - } + val ChangeCount = Int32Member("cc", JsonPath("properties", "changeCount")) + } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StringMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StringMember.kt new file mode 100644 index 0000000000..33876de60d --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/StringMember.kt @@ -0,0 +1,47 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.STRING +import kotlin.js.JsName + +class StringMember() : TypedMember() { + override fun verify(): StringMember { + if (dataType != STRING) { + throw illegalState("The member was illegally cast, expected subtype: $STRING, found: $dataType") + } + return this + } + + /** Creates a new string member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = STRING + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a string member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != STRING) throw illegalArg("The given member is not of string type") + this.name = member.name + this.dataType = STRING + this.path = path?.validate() ?: member.path + } + + /** Retrieves the string value of this member from the given feature. */ + fun get(feature: NakshaFeature): String? = getString(feature) + + /** + * Retrieves the string value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): String? = getString(tuple) + + /** Sets the string value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: String): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TagListMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TagListMember.kt new file mode 100644 index 0000000000..a417d7e238 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TagListMember.kt @@ -0,0 +1,49 @@ +package naksha.model.objects + +import naksha.base.ListProxy +import naksha.model.TagList +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.TAG_LIST +import kotlin.js.JsName + +class TagListMember() : TypedMember() { + override fun verify(): TagListMember { + if (dataType != TAG_LIST) { + throw illegalState("The member was illegally cast, expected subtype: $TAG_LIST, found: $dataType") + } + return this + } + + /** Creates a new tag list member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = TAG_LIST + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a tag list member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != TAG_LIST) throw illegalArg("The given member is not of tag_list type") + this.name = member.name + this.dataType = TAG_LIST + this.path = path?.validate() ?: member.path + } + + /** Retrieves the tag list value of this member from the given feature. */ + fun get(feature: NakshaFeature): TagList? = getTagList(feature) + + /** + * Retrieves the tag list value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): TagList? = getTagList(tuple) + + /** Sets the tag list value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: ListProxy<*>): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TagsMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TagsMember.kt new file mode 100644 index 0000000000..1311184dcc --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TagsMember.kt @@ -0,0 +1,48 @@ +package naksha.model.objects + +import naksha.model.TagMap +import naksha.model.Tuple +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.TAG_MAP +import kotlin.js.JsName + +class TagsMember() : TypedMember() { + override fun verify(): TagsMember { + if (dataType != TAG_MAP) { + throw illegalState("The member was illegally cast, expected subtype: $TAG_MAP, found: $dataType") + } + return this + } + + /** Creates a new tag map member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = TAG_MAP + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a tag map member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != TAG_MAP) throw illegalArg("The given member is not of tags type") + this.name = member.name + this.dataType = TAG_MAP + this.path = path?.validate() ?: member.path + } + + /** Retrieves the tag map value of this member from the given feature. */ + fun get(feature: NakshaFeature): TagMap? = getTagMap(feature) + + /** + * Retrieves the tag map value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): TagMap? = getTagMap(tuple) + + /** Sets the tag map value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: TagMap): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TupleNumberMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TupleNumberMember.kt new file mode 100644 index 0000000000..9a2c091461 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TupleNumberMember.kt @@ -0,0 +1,48 @@ +package naksha.model.objects + +import naksha.model.Tuple +import naksha.model.TupleNumber +import naksha.model.illegalArg +import naksha.model.illegalState +import naksha.model.objects.MemberType.MemberType_C.TUPLE_NUMBER +import kotlin.js.JsName + +class TupleNumberMember() : TypedMember() { + override fun verify(): TupleNumberMember { + if (dataType != TUPLE_NUMBER) { + throw illegalState("The member was illegally cast, expected subtype: $TUPLE_NUMBER, found: $dataType") + } + return this + } + + /** Creates a new tuple number member with the given name and an optional custom JSON path. */ + @JsName("of") + constructor(name: String, path: JsonPath? = null) : this() { + this.name = name + this.dataType = TUPLE_NUMBER + this.path = path ?: JsonPath(listOf("properties", name)) + this.path.validate() + } + + /** Creates a tuple number member from an existing [Member], validating its type. */ + @JsName("from") + constructor(member: Member, path: JsonPath? = null) : this() { + if (member.dataType != TUPLE_NUMBER) throw illegalArg("The given member is not of tuple_number type") + this.name = member.name + this.dataType = TUPLE_NUMBER + this.path = path?.validate() ?: member.path + } + + /** Retrieves the tuple number value of this member from the given feature. */ + fun get(feature: NakshaFeature): TupleNumber? = getTupleNumber(feature) + + /** + * Retrieves the tuple number value of this member from the given tuple. + * TODO: When no such member exists in membersBook, should search along [path] in [tuple.featureBytes], but currently cannot due to JbDecoder2 limits. + */ + @JsName("getFromTuple") + fun get(tuple: Tuple): TupleNumber? = getTupleNumber(tuple) + + /** Sets the tuple number value of this member on the given feature. */ + fun set(feature: NakshaFeature, value: TupleNumber): Any? = setPath(feature, path, value) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TypedMember.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TypedMember.kt new file mode 100644 index 0000000000..fa657df880 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/TypedMember.kt @@ -0,0 +1,14 @@ +package naksha.model.objects + +/** + * Marker class of typed members. + * @since 3.0 + */ +abstract class TypedMember> : Member() { + /** + * Tests if the underlying map has the correct type. + * @return this. + * @throws naksha.model.NakshaException with [ILLEGAL_STATE][naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE], if the type was illegally cast. + */ + abstract fun verify(): T +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzIndices.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzIndices.kt new file mode 100644 index 0000000000..06bec6abee --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzIndices.kt @@ -0,0 +1,143 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.objects + +import kotlin.js.JsExport +import kotlin.js.JsStatic +import kotlin.jvm.JvmField + +/** + * The default indices created for a Data-Hub (XYZ) compatible collection. + * + * This is the index counterpart of [XyzMembers]: It indexes the members declared there _(e.g. [XyzTags][XyzMembers.XyzTags], [XyzAppId][XyzMembers.XyzAppId], [XyzHereTile][XyzMembers.XyzHereTile])_ and is applied via [NakshaCollection.withXyzIndices]. + * + * An index refers to a member by its identity _(name)_, **not** by JSON path. Therefore, the name of a member is very significant. + * @since 3.0 + */ +@JsExport +class XyzIndices private constructor() { + + companion object XyzIndices_C { + + /** + * `here_tile` — index on `here_tile`, `fn`, `version` (WHERE `here_tile IS NOT NULL`). + * See [XyzMembers.XyzHereTile]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzHereTile = Index("here_tile", "here_tile", "fn", "version") + + /** + * `app_id` — index on `app_id`, `updated_at`, `fn`, `version` (WHERE `app_id IS NOT NULL`). + * See [XyzMembers.XyzAppId]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzAppId = Index("app_id", "app_id", "updated_at", "fn", "version") + + /** + * `author` — index on the effective author and author timestamp, `fn`, `version` + * (WHERE effective author IS NOT NULL). See [XyzMembers.XyzAuthor]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzAuthor = Index("author", "author", "author_ts", "fn", "version") + + /** + * `tags` — inverted ([IndexType.TAG_LIST]) index over the `tags` member, supporting element + * containment queries. See [XyzMembers.XyzTags]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzTags = Index("tags", "tags") + + /** + * `feature_type` — index on `ft`, `fn`, `version` (WHERE `ft IS NOT NULL`). + * See [XyzMembers.XyzFeatureType]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzFeatureType = Index("feature_type", "ft", "fn", "version") + + /** + * `cv0` — index on custom numeric value 0, `fn`, `version` (WHERE `cv0 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue0 = Index("cv0", "cv0", "fn", "version") + + /** + * `cv1` — index on custom numeric value 1, `fn`, `version` (WHERE `cv1 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue1 = Index("cv1", "cv1", "fn", "version") + + /** + * `cv2` — index on custom numeric value 2, `fn`, `version` (WHERE `cv2 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue2 = Index("cv2", "cv2", "fn", "version") + + /** + * `cv3` — index on custom numeric value 3, `fn`, `version` (WHERE `cv3 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue3 = Index("cv3", "cv3", "fn", "version") + + /** + * `cs0` — index on custom string value 0, `fn`, `version` (WHERE `cs0 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString0 = Index("cs0", "cs0", "fn", "version") + + /** + * `cs1` — index on custom string value 1, `fn`, `version` (WHERE `cs1 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString1 = Index("cs1", "cs1", "fn", "version") + + /** + * `cs2` — index on custom string value 2, `fn`, `version` (WHERE `cs2 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString2 = Index("cs2", "cs2", "fn", "version") + + /** + * `cs3` — index on custom string value 3, `fn`, `version` (WHERE `cs3 IS NOT NULL`). + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString3 = Index("cs3", "cs3", "fn", "version") + + /** + * `ref_point` — spatial index over the reference-point geometry member. + * @since 3.0 + * @see [XyzMembers.XyzReferencePoint] + */ + @JvmField @JsStatic + val XyzReferencePoint = Index("ref_point", "ref_point") + + /** + * All indices for a default XYZ collection. + * @since 3.0 + */ + @JvmField @JsStatic + val ALL: List = listOf( + XyzHereTile, + XyzAppId, + XyzAuthor, + XyzTags, + XyzFeatureType, + XyzCustomValue0, XyzCustomValue1, XyzCustomValue2, XyzCustomValue3, + XyzCustomString0, XyzCustomString1, XyzCustomString2, XyzCustomString3, + XyzReferencePoint, + StandardIndices.Geometry, + ) + } +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzMembers.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzMembers.kt new file mode 100644 index 0000000000..a3ac9c56ec --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzMembers.kt @@ -0,0 +1,256 @@ +@file:OptIn(ExperimentalJsExport::class, ExperimentalJsStatic::class) + +package naksha.model.objects + +import naksha.geo.SpGeometry +import naksha.model.TupleNumber +import kotlin.js.ExperimentalJsExport +import kotlin.js.ExperimentalJsStatic +import kotlin.js.JsExport +import kotlin.js.JsStatic +import kotlin.jvm.JvmField + +/** + * All members being part of the classic XYZ-Hub architecture, plus further extensions added later in Data-Hub and Naksha v1, v2. All internal administrative object are stored in this format. + * @since 3.0 + */ +@JsExport +class XyzMembers private constructor() { + + companion object XyzMembers_C { + // ------------------------------------------------------------------------- + // Mandatory members — storage-managed, always present. + // ------------------------------------------------------------------------- + + /** + * The same as [StandardMembers.Tn], but with a Data-Hub compatible path. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzTn = TupleNumberMember(StandardMembers.Tn, JsonPath("properties", "@ns:com:here:xyz", "uuid")) + + /** + * The same as [StandardMembers.NextVersion], but with a Data-Hub compatible path. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzNextVersion = Int64Member(StandardMembers.NextVersion, JsonPath("properties", "@ns:com:here:xyz", "nextVersion")) + + /** + * The same as [StandardMembers.GlobalBookFeatureNumber], but with a Data-Hub compatible path. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzGlobalBookFeatureNumber = Int64Member(StandardMembers.GlobalBookFeatureNumber, JsonPath("properties", "@ns:com:here:xyz", "globalBookFn")) + + /** + * The same as [StandardMembers.Feature]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzFeature = ByteArrayMember(StandardMembers.Feature, JsonPath()) + + /** + * The same as [StandardMembers.Feature]. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzId = StringMember(StandardMembers.Id, JsonPath("id")) + + // ------------------------------------------------------------------------- + // Optional members. + // ------------------------------------------------------------------------- + + /** + * `geo` — feature geometry stored as TWKB. `null` if the feature has no geometry. + * Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzGeometry = SpatialMember(StandardMembers.Geometry, JsonPath("geometry")) + + /** + * `updated_at` — millisecond epoch timestamp of the last modification. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzUpdatedAt = Int64Member("updated_at", JsonPath("properties", "@ns:com:here:xyz", "updatedAt")) + + /** + * `created_at` — millisecond epoch timestamp of the initial creation. `null` means the + * timestamp equals [XyzUpdatedAt] (first-write optimisation). Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCreatedAt = Int64Member("created_at", JsonPath("properties", "@ns:com:here:xyz", "createdAt")) + + /** + * `author_ts` — millisecond epoch timestamp of the last author change. `null` means the + * timestamp equals [XyzUpdatedAt]. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzAuthorTimestamp = Int64Member("author_ts", JsonPath("properties", "@ns:com:here:xyz", "authorTs")) + + /** + * `hash` — content hash of the tuple, computed by the storage. `null` if not recorded. + * Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzHash = Int32Member("hash", JsonPath("properties", "@ns:com:here:xyz", "hash")) + + /** + * `here_tile` — HERE tile key (binary) of the reference point. `null` if not known. + * Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzHereTile = Int32Member("here_tile", JsonPath("properties", "@ns:com:here:xyz", "hereTile")) + + /** + * `cc` — change-count: how many times this feature has been modified. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzChangeCount = Int32Member(StandardMembers.ChangeCount, JsonPath("properties", "@ns:com:here:xyz", "changeCount")) + + /** + * `base_tn` — base tuple-number (`BYTE_ARRAY`), set when a three-way merge was performed. + * `null` otherwise. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzBaseTn = TupleNumberMember("base_tn", JsonPath("properties", "@ns:com:here:xyz", "base")) + + /** + * `app_id` — identifier of the application that wrote this tuple. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzAppId = StringMember("app_id", JsonPath("properties", "@ns:com:here:xyz", "appId")) + + /** + * `author` — identifier of the human author that takes ownership for this tuple. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzAuthor = StringMember("author", JsonPath("properties", "@ns:com:here:xyz", "author")) + + /** + * `origin` — stringified reference to the originating feature when this feature was forked or + * copied from another storage, map, or collection. Used for rebase support. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzOrigin = StringMember("origin", JsonPath("properties", "@ns:com:here:xyz", "origin")) + + /** + * `target` — stringified reference to the feature into which this feature was joined. + * Set when multiple features are merged into one. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzTarget = StringMember("target", JsonPath("properties", "@ns:com:here:xyz", "target")) + + /** + * `ft` — feature-type string. `null` when it matches the collection's + * [default feature type][NakshaCollection.defaultFeatureType], avoiding redundant storage. + * Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzFeatureType = StringMember("ft", JsonPath("properties", "featureType")) + + /** + * `cv0` — custom numeric value 0 (`FLOAT64`). `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue0 = Float64Member("cv0", JsonPath("properties", "@ns:com:here:xyz", "cv0")) + + /** + * `cv1` — custom numeric value 1 (`FLOAT64`). `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue1 = Float64Member("cv1", JsonPath("properties", "@ns:com:here:xyz", "cv1")) + + /** + * `cv2` — custom numeric value 2 (`FLOAT64`). `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue2 = Float64Member("cv2", JsonPath("properties", "@ns:com:here:xyz", "cv2")) + + /** + * `cv3` — custom numeric value 3 (`FLOAT64`). `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomValue3 = Float64Member("cv3", JsonPath("properties", "@ns:com:here:xyz", "cv3")) + + /** + * `cs0` — custom string value 0. `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString0 = StringMember("cs0", JsonPath("properties", "@ns:com:here:xyz", "cs0")) + + /** + * `cs1` — custom string value 1. `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString1 = StringMember("cs1", JsonPath("properties", "@ns:com:here:xyz", "cs1")) + + /** + * `cs2` — custom string value 2. `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString2 = StringMember("cs2", JsonPath("properties", "@ns:com:here:xyz", "cs2")) + + /** + * `cs3` — custom string value 3. `null` if not used. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzCustomString3 = StringMember("cs3", JsonPath("properties", "@ns:com:here:xyz", "cs3")) + + /** + * `tags` — feature tags, the classic XYZ tags array located at + * `properties -> @ns:com:here:xyz -> tags` (e.g. `["foo", "bar"]`), stored as a + * [tag_list][MemberType.TAG_LIST] of unique strings. The list is persisted unmodified, so the + * element order is preserved when reading the feature back. `null` if the feature has no + * tags. Supports element containment queries via [IndexType.TAG_LIST]. Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzTags = TagListMember("tags", JsonPath("properties", "@ns:com:here:xyz", "tags")) + + /** + * `ref_point` — geometry reference point (always a single point), stored as TWKB. Used to compute the [XyzHereTile] value. `null` if the feature has no explicit reference point. + * Default member. + * @since 3.0 + */ + @JvmField @JsStatic + val XyzReferencePoint = SpatialMember("ref_point", JsonPath("referencePoint")) + + /** + * All members of XYZ compatible features. + * @since 3.0 + */ + @JvmField @JsStatic + val ALL: List = listOf( + XyzTn, XyzNextVersion, XyzGlobalBookFeatureNumber, XyzFeature, XyzId, XyzGeometry, + // Optional members + XyzUpdatedAt, XyzCreatedAt, XyzAuthorTimestamp, + XyzHash, XyzHereTile, XyzChangeCount, XyzBaseTn, + XyzAppId, XyzAuthor, XyzOrigin, XyzTarget, XyzFeatureType, + XyzCustomValue0, XyzCustomValue1, XyzCustomValue2, XyzCustomValue3, + XyzCustomString0, XyzCustomString1, XyzCustomString2, XyzCustomString3, + XyzTags, XyzReferencePoint + ) + } +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzProcessors.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzProcessors.kt new file mode 100644 index 0000000000..3513df65ee --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/objects/XyzProcessors.kt @@ -0,0 +1,82 @@ +package naksha.model.objects + +import naksha.base.Int64 +import naksha.base.Platform +import naksha.model.IMemberProcessor +import naksha.model.ISession +import kotlin.jvm.JvmStatic + +/** + * A singleton with standard processors to restore Xyz-Hub compatibility. + * + * To restore the functionality of Xyz-Hub, the session needs to be patched like: + * ```kotlin + * val processors = session.processors + * val backup = processors.backup(clear=true) + * try { + * processors.addProcessor(XyzMembers.XyzCreatedAt, XyzProcessors.xyzCreatedAt) + * processors.addProcessor(XyzMembers.XyzUpdatedAt, XyzProcessors.xyzUpdatedAt) + * processors.addProcessor(XyzMembers.XyzAppId, XyzProcessors.xyzAppId) + * processors.addProcessor(XyzMembers.XyzAuthor, XyzProcessors.xyzAuthor) + * processors.addProcessor(XyzMembers.XyzAuthorTimestamp, XyzProcessors.xyzAuthorTimestamp) + * // ... + * // perform all normal session operations. + * // the added processors will ensure that the members having correct values. + * } finally { + * processors.restore(backup, clear=true, consume=true) + * } + * ``` + * @since 3.0 + */ +class XyzProcessors private constructor() { + companion object XyzProcessor_C { + /** + * Ensures that [XyzCreatedAt][naksha.model.objects.XyzMembers.XyzMembers_C.XyzCreatedAt] is set correctly. + * @since 3.0 + */ + @JvmStatic + val xyzCreatedAt = fun(session: ISession, collection: NakshaCollection, feature: NakshaFeature, member: Member, value: Any?): Int64 { + if (value is Int64) return value + if (value is Number) return Int64(value.toLong()) + return Platform.currentMillis() + } as IMemberProcessor + + /** + * Ensures that [XyzUpdatedAt][naksha.model.objects.XyzMembers.XyzMembers_C.XyzUpdatedAt] is set correctly. + * @since 3.0 + */ + @JvmStatic + val xyzUpdatedAt = fun(session: ISession, collection: NakshaCollection, feature: NakshaFeature, member: Member, value: Any?): Int64 { + return Platform.currentMillis() + } as IMemberProcessor + + /** + * Ensures that [XyzAppId][naksha.model.objects.XyzMembers.XyzMembers_C.XyzAppId] is set correctly. + * @since 3.0 + */ + @JvmStatic + val xyzAppId = fun(session: ISession, collection: NakshaCollection, feature: NakshaFeature, member: Member, value: Any?): String { + return session.options.appId + } as IMemberProcessor + + /** + * Ensures that [XyzAuthor][naksha.model.objects.XyzMembers.XyzMembers_C.XyzAuthor] is set correctly. + * @since 3.0 + */ + @JvmStatic + val xyzAuthor = fun(session: ISession, collection: NakshaCollection, feature: NakshaFeature, member: Member, value: Any?): String? { + val author = session.options.author + return author ?: value as String? + } as IMemberProcessor + + /** + * Ensures that [XyzAuthorTimestamp][naksha.model.objects.XyzMembers.XyzMembers_C.XyzAuthorTimestamp] is set correctly. + * @since 3.0 + */ + @JvmStatic + val xyzAuthorTimestamp = fun(session: ISession, collection: NakshaCollection, feature: NakshaFeature, member: Member, value: Any?): Int64? { + val author = session.options.author + return if (author != null) Platform.currentMillis() else value as Int64? + } as IMemberProcessor + } +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/FeatureTuple.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/FeatureTuple.kt index 20b0bae712..a83e922507 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/FeatureTuple.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/FeatureTuple.kt @@ -4,7 +4,11 @@ package naksha.model.request import naksha.base.Platform import naksha.model.* +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.objects.Member +import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature +import naksha.model.objects.StandardMembers import kotlin.js.JsExport import kotlin.js.JsName import kotlin.jvm.JvmField @@ -36,9 +40,20 @@ open class FeatureTuple( * @param feature the [NakshaFeature] from which to create this [FeatureTuple]. * @since 3.0 */ - @JsName("fromNakshaFeature") + @JsName("fromFeatureAndCollection") @Suppress("LeakingThis") - constructor(feature: NakshaFeature) : this(feature.tupleNumber) { + constructor(feature: NakshaFeature, collection: NakshaCollection) : this(feature, collection.useMember(StandardMembers.Tn)) + + /** + * Create a feature-tuple from a [NakshaFeature]. + * @param feature the [NakshaFeature] from which to create this [FeatureTuple]. + * @since 3.0 + */ + @JsName("fromFeatureAndMember") + @Suppress("LeakingThis") + constructor(feature: NakshaFeature, tupleNumberMember: Member) : + this(tupleNumberMember.getTupleNumber(feature) ?: throw NakshaException(ILLEGAL_ARGUMENT, "Failed to get tuple-number of feature")) + { this.feature = feature } @@ -55,7 +70,7 @@ open class FeatureTuple( */ val id: String? get() { - val member = tuple?.getStringMember(naksha.model.objects.StandardMembers.Id) + val member = tuple?.getString(StandardMembers.Id) if (member != null) return member return feature?.id } @@ -72,14 +87,15 @@ open class FeatureTuple( * - Setting the value to an explicit [NakshaFeature] will disable the automatic cache updates, when the [tuple] is modified. * - **Beware**: If the returned feature is modified, this will as well modify the cached version. * @since 3.0 - * @see [Tuple.toNakshaFeature] + * @see [Tuple.decodeFeature] */ open var feature: NakshaFeature? get() { var feature = cachedFeature val tuple = this.tuple if (tuple != null && tuple !== cachedTuple && !doNotAutoUpdate) { - feature = tuple.toNakshaFeature() + // TODO: We need a global book for decoding, we should make Tuples explicit for clients! + feature = tuple.decodeFeature(null) cachedFeature = feature cachedJson = null } @@ -115,5 +131,5 @@ open class FeatureTuple( * @return a new copy of the tuple converted into a feature. * @since 3.0 */ - open fun newFeature(): NakshaFeature? = tuple?.toNakshaFeature() + open fun newFeature(): NakshaFeature? = tuple?.decodeFeature(null) // TODO: Same as above, we need to change this generally! } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/OrderBy.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/OrderBy.kt index eb0fce628b..f6beaf9de6 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/OrderBy.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/OrderBy.kt @@ -5,10 +5,10 @@ package naksha.model.request import naksha.base.NotNullEnum import naksha.base.NullableProperty import naksha.base.AnyObject -import naksha.model.request.query.MetaColumn +import naksha.model.objects.Member +import naksha.model.objects.StandardMembers import naksha.model.request.query.SortOrder import naksha.model.request.query.SortOrder.SortOrderCompanion.ANY -import naksha.model.request.query.SortOrder.SortOrderCompanion.DESCENDING import kotlin.js.JsExport import kotlin.js.JsName import kotlin.js.JsStatic @@ -18,27 +18,39 @@ import kotlin.jvm.JvmStatic /** * Describes a sort order in a [result-set][naksha.model.request.IResultSet]. * - * **Warning**: Using custom ordering may not be supported by the storage. The best is to only use the pre-defined sort orders: - * - [deterministic] - * - [version] - * - [id] - * - [author] - * - * @constructor Creating an ordering, where the details + * @constructor Create an ordering. */ @JsExport class OrderBy() : AnyObject() { /** - * Create a new order. - * @param column the column by which to order by, if _null_, any column is okay, just a deterministic order is requested. + * Create an order. + * + * If [member] is `null`, [next] must be `null` as well. + * @param member the member by which to order by, if _null_, any member is accepted, only a deterministic order is requested. + * @param order the sort order, if [ANY][SortOrder.ANY] is given, then the storage can pick whatever is faster. + * @param next if a second-level order is requested; i.e. order by `id`, then by `version`. + */ + @JsName("ofMember") + @JvmOverloads + constructor(member: Member?, order: SortOrder = ANY, next: OrderBy? = null) : this() { + this.member = member?.name + this.sortOrder = order + this.next = next + } + + /** + * Create an order. + * + * If [member] is `null`, [next] must be `null` as well. + * @param memberName the name fo the member by which to order by, if _null_, any member is accepted, only a deterministic order is requested. * @param order the sort order, if [ANY][SortOrder.ANY] is given, then the storage can pick whatever is faster. - * @param next if a second-level order is requested, for example order by `id` and then by `txn`, and finally by `uid`. + * @param next if a second-level order is requested; i.e. order by `id`, then by `version`. */ @JsName("of") @JvmOverloads - constructor(column: MetaColumn?, order: SortOrder = ANY, next: OrderBy? = null) : this() { - this.column = column + constructor(memberName: String?, order: SortOrder = ANY, next: OrderBy? = null) : this() { + this.member = memberName this.sortOrder = order this.next = next } @@ -56,45 +68,48 @@ class OrderBy() : AnyObject() { */ @JsStatic @JvmStatic - fun version(): OrderBy = OrderBy(MetaColumn.version()) + fun version(): OrderBy = OrderBy(StandardMembers.Version) /** * Supported ordering by `tuple-number` _(so by storage, map, collection, feature, version, uid). */ @JsStatic @JvmStatic - fun tupleNumber(): OrderBy = OrderBy(column=MetaColumn.tupleNumber()) + fun tupleNumber(): OrderBy = OrderBy(StandardMembers.Tn) /** * Supported ordering by `id` and `version`. */ @JsStatic @JvmStatic - fun id(): OrderBy = OrderBy(MetaColumn.id(), next = version()) - - /** - * Supported ordering by `author`, `updatedAt`, `id`, and `version`. - */ - @JsStatic - @JvmStatic - fun author(): OrderBy = OrderBy(MetaColumn.author(), next = OrderBy(MetaColumn.updatedAt(), DESCENDING, id())) + fun id(): OrderBy = OrderBy(StandardMembers.Id, next = version()) - private val COLUMN_OR_NULL = NullableProperty(MetaColumn::class) + private val STRING_OR_NULL = NullableProperty(String::class) private val SORT_ORDER = NotNullEnum(SortOrder::class) { _, _ -> ANY } private val NEXT_OR_NULL = NullableProperty(OrderBy::class) } /** - * The [MetaColumn] by which to order, if `null`, then deterministic ordering is requested. + * The name of the [Member] by which to order, if `null`, then deterministic ordering is requested. * @since 3.0 */ - var column by COLUMN_OR_NULL + var member: String? by STRING_OR_NULL + + /** + * @see [member] + */ + @JsName("withMember") + fun withMember(member: Member?): OrderBy { + this.member = member?.name + return this + } /** - * @see [column] + * @see [member] */ - fun withColumn(value: MetaColumn?): OrderBy { - column = value + @JsName("withMemberName") + fun withMember(name: String?): OrderBy { + this.member = name return this } @@ -113,7 +128,7 @@ class OrderBy() : AnyObject() { } /** - * Optionally next order, so after ordering by this [MetaColumn], order those that are equal by the given next one. If `null`, the order will switch to just be deterministic, when the [MetaColumn] values are equal so far _(internally storages are recommended to use the [TupleNumber][naksha.model.TupleNumber] to the final ordering)_. + * Optionally next order, so after ordering by this [Member], order those that are equal by the given next one. If `null`, the order will switch to just be deterministic, when the [Member] values are equal so far _(internally storages are recommended to use the [TupleNumber][naksha.model.TupleNumber] to the final ordering)_. * @since 3.0 */ var next by NEXT_OR_NULL @@ -130,20 +145,16 @@ class OrderBy() : AnyObject() { * Tests if this represents deterministic ordering, which means that no specific column is selected (`null`), the order is [Any], and no other conditions are given ([next] = `null`). * @return `true` if this represents the deterministic order; `false` otherwise. */ - fun isDeterministic(): Boolean = column == null && sortOrder == ANY && next == null + fun isDeterministic(): Boolean = member == null && sortOrder == ANY && next == null override fun equals(other: Any?): Boolean { if (other !is OrderBy) return false - return column == other.column + return member == other.member && sortOrder == other.sortOrder && next == other.next } override fun hashCode(): Int = super.hashCode() - override fun toString(): String { - val col = column ?: return "" - val next = this.next - return "${col.name} $sortOrder${if (next != null) ", $next" else ""}" - } + override fun toString(): String = "OrderBy(member=$member, sortOrder=$sortOrder, next=$next)" } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/PropertyFilter.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/PropertyFilter.kt index 620b60f144..db403b5189 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/PropertyFilter.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/PropertyFilter.kt @@ -3,11 +3,17 @@ package naksha.model.request import naksha.base.AnyList import naksha.base.AnyObject import naksha.base.Platform +import naksha.base.Platform.PlatformCompanion.UNDEFINED +import naksha.base.PlatformList +import naksha.base.PlatformListApi +import naksha.base.PlatformListApi.PlatformListApiCompanion.array_get +import naksha.base.PlatformListApi.PlatformListApiCompanion.array_get_length +import naksha.base.PlatformMap +import naksha.base.PlatformMapApi +import naksha.base.PlatformMapApi.PlatformMapApiCompanion.map_get import naksha.base.PlatformUtil import naksha.base.Proxy -import naksha.model.Naksha -import naksha.model.Naksha.NakshaCompanion.cache -import naksha.model.Naksha.NakshaCompanion.getStorageByNumber +import naksha.model.objects.JsonPath import naksha.model.objects.NakshaFeature import naksha.model.request.query.* @@ -21,10 +27,7 @@ class PropertyFilter(val req: ReadFeatures) : ResultFilter { */ override fun filter(featureTuple: FeatureTuple): FeatureTuple? { val pSearch = req.query.properties ?: return featureTuple - val tuple = featureTuple.tuple ?: return null - val sn = tuple.storageNumber - val dictReader = getStorageByNumber(sn) ?: cache.getDictReader(sn) - val feature = Naksha.decodeFeature(tuple.feature, dictReader) ?: return null + val feature = featureTuple.feature ?: return null return if (resolvePropsQueryOnFeature(pSearch, feature)) featureTuple else null } @@ -38,8 +41,7 @@ class PropertyFilter(val req: ReadFeatures) : ResultFilter { is POr -> return pQuery.any { resolvePropsQueryOnFeature(it, feature) } is PNot -> return !resolvePropsQueryOnFeature(pQuery.query, feature) is PQuery -> { - val path = pQuery.property.path.filterNotNull() - val propValue = walkPath(feature, path) + val propValue = walkPath(feature, pQuery.property.path) return resolveEachOp(pQuery.op, propValue, pQuery.value) } } @@ -47,31 +49,44 @@ class PropertyFilter(val req: ReadFeatures) : ResultFilter { } /** - * Walk a property path on an object. Returns [Platform.UNDEFINED] if the path does not exist. + * Walk a property path on an object or array. + * @return [Platform.UNDEFINED] if the path does not exist. */ - private fun walkPath(root: Any?, path: List): Any? { - var current: Any? = root + private fun walkPath(objectOrArray: Any?, path: JsonPath): Any? { + var current: Any? = objectOrArray for (key in path) { + if (key == null) return UNDEFINED current = when (current) { is AnyList -> { - val index = key.toIntOrNull() ?: return Platform.UNDEFINED - if (index < 0 || index >= current.size) return Platform.UNDEFINED + val index: Int = (key as Number?)?.toInt() ?: return UNDEFINED + if (index < 0 || index >= current.size) return UNDEFINED current[index] } - is AnyObject -> if (current.containsKey(key)) current[key] else return Platform.UNDEFINED is NakshaFeature -> { val raw = current.getRaw(key) - if (raw === Platform.UNDEFINED) return Platform.UNDEFINED else raw + if (raw === UNDEFINED) return UNDEFINED else raw } - else -> return Platform.UNDEFINED + is AnyObject -> if (current.containsKey(key)) current[key] else return UNDEFINED + is PlatformList -> { + val index: Int = (key as Number?)?.toInt() ?: return UNDEFINED + if (index < 0 || index >= array_get_length(current)) return UNDEFINED + array_get(current, index) + } + is PlatformMap -> { + if (key !is String) return UNDEFINED + map_get(current, key) + } + else -> return UNDEFINED } } + if (current is PlatformList) return current.proxy(AnyList::class) + if (current is PlatformMap) return current.proxy(AnyObject::class) return current } private fun resolveEachOp(op: AnyOp, featureProperty: Any?, queryProperty: Any?) : Boolean { return when (op) { - AnyOp.EXISTS -> featureProperty != Platform.UNDEFINED + AnyOp.EXISTS -> featureProperty != UNDEFINED AnyOp.IS_NULL -> featureProperty == null AnyOp.IS_NOT_NULL -> featureProperty != null AnyOp.IS_TRUE -> featureProperty == true @@ -149,7 +164,7 @@ class PropertyFilter(val req: ReadFeatures) : ResultFilter { return try { Proxy.box(Platform.fromJSON(json), Any::class) } catch (e: Exception) { - Platform.PlatformCompanion.logger.warn("JSON parsing failed for string that appeared to be JSON: $json") + Platform.logger.warn("JSON parsing failed for string that appeared to be JSON: $json") json } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadCollections.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadCollections.kt index d490264d91..57e04bbc96 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadCollections.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadCollections.kt @@ -8,7 +8,6 @@ import naksha.base.StringList import naksha.model.Naksha import kotlin.js.ExperimentalJsExport import kotlin.js.JsExport -import kotlin.js.JsName /** * A request to read [collection features][naksha.model.objects.NakshaCollection] from a map of the storage. @@ -81,8 +80,8 @@ open class ReadCollections : ReadRequest() { */ fun toReadFeatures(): ReadFeatures { val req = ReadFeatures() - req.mapId = mapId - req.collectionIds.add(Naksha.COLLECTIONS_COL) + req.catalogId = mapId + req.collectionId = Naksha.COLLECTIONS_COL_ID req.featureIds.addAll(collectionIds) return req } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadFeatures.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadFeatures.kt index 498aa53ed8..f0b973efec 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadFeatures.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadFeatures.kt @@ -2,14 +2,19 @@ package naksha.model.request +import naksha.base.Int64 import naksha.base.NotNullProperty import naksha.base.NullableProperty import naksha.base.StringList import naksha.model.GuidList import naksha.model.Version +import naksha.model.illegalArg +import naksha.model.request.ops.Op import naksha.model.request.query.IPropertyQuery import naksha.model.request.query.ITagQuery import kotlin.js.JsExport +import kotlin.js.JsName +import kotlin.math.max /** * Read features from a collection of a map of a storage. @@ -23,30 +28,26 @@ open class ReadFeatures : ReadRequest() { companion object ReadFeatures_C { private val STRING_OR_NULL = NullableProperty(String::class) - private val BOOLEAN_OR_FALSE = - NotNullProperty(Boolean::class) { _, _ -> false } - private val BOOLEAN_OR_TRUE = - NotNullProperty(Boolean::class) { _, _ -> true } - private val INT_OR_1 = NotNullProperty(Int::class) { _, _ -> 1 } - private val VERSION_OR_NULL = NullableProperty(Version::class) - private val STRING_LIST = NotNullProperty(StringList::class) + private val STRING_LIST = NotNullProperty(StringList::class) { _, _ -> StringList() } + private val BOOLEAN_OR_FALSE = NotNullProperty(Boolean::class) { _, _ -> false } private val ORDER_BY_OR_NULL = NullableProperty(OrderBy::class) private val GUID_LIST = NotNullProperty(GuidList::class) private val QUERY = NotNullProperty(RequestQuery::class) + private val OP_OR_NULL = NullableProperty(Op::class) } /** - * The id of the map from which to read. + * The id of the catalog from which to read. * * @since 3.0 */ - var mapId by STRING_OR_NULL + var catalogId by STRING_OR_NULL /** - * @see [mapId] + * @see [catalogId] */ - open fun withMapId(value: String?): ReadFeatures { - mapId = value + open fun withCatalogId(value: String?): ReadFeatures { + catalogId = value return this } @@ -59,6 +60,7 @@ open class ReadFeatures : ReadRequest() { * * @since 3.0 */ + @Deprecated("Remove, need always to be done on the client using post-filtering", replaceWith = ReplaceWith("op")) open fun withPropertyQuery(pQuery: IPropertyQuery?): ReadFeatures { this.query.properties = pQuery this.resultFilters.removeAll { it is PropertyFilter } @@ -75,6 +77,7 @@ open class ReadFeatures : ReadRequest() { * This method comes handy if [IPropertyQuery] was mutated outside of this class scope, * in such cases we need to populate the filter once again so it will be in sync with the query */ + @Deprecated("Replaced with op", replaceWith = ReplaceWith("op")) fun refreshPropertyFilter() { this.resultFilters.removeAll { it is PropertyFilter } if(query.properties != null) { @@ -91,6 +94,7 @@ open class ReadFeatures : ReadRequest() { * * @since 3.0 */ + @Deprecated("Replaced with op", replaceWith = ReplaceWith("op")) open fun withTagQuery(tQuery: ITagQuery?): ReadFeatures { this.query.tags = tQuery return this @@ -100,101 +104,144 @@ open class ReadFeatures : ReadRequest() { * Ids of collections to read. * @since 3.0 */ - var collectionIds by STRING_LIST + var collectionId: String? by STRING_OR_NULL /** - * Adds the given collection-id into [collectionIds], if it is not already in it. - * @param collectionId the collection-id to add. + * Sets the collection-id into [collectionId]. + * @param collectionId the collection-id to set. * @return this. * @since 3.0 */ - open fun addCollectionId(collectionId: String?): ReadFeatures { - if (!collectionIds.contains(collectionId)) collectionIds.add(collectionId) - return this - } - - /** - * Adds the given collection-ids into [collectionIds], if it is not already in it. - * @param collectionIds the collection-ids to add. - * @return this. - * @since 3.0 - */ - open fun addCollectionIds(vararg collectionIds: String): ReadFeatures { - val ids = this.collectionIds - @Suppress("SENSELESS_COMPARISON") - if (collectionIds != null && collectionIds.isNotEmpty()) { - for (id in collectionIds) if (!ids.contains(id)) ids.add(id) - } + open fun withCollectionId(collectionId: String?): ReadFeatures { + this.collectionId = collectionId return this } /** * Extend the request to include features that are in a deleted state _(defaults to `false`)_. */ - var queryDeleted by BOOLEAN_OR_FALSE + var queryDeleted: Boolean by BOOLEAN_OR_FALSE /** * Extend the request to search through historic states of features _(defaults to `false`)_. * - * Setting this to `true` adds past states from the **HISTORY** section to the result set. When - * [versions] is greater than `1`, results are ordered automatically by the storage in reverse - * version order, so the most recent state is returned first. + * Setting this to `true` adds past states from the **HISTORY** section to the result set. When [versions] is greater than `1`, results are ordered automatically by the storage in reverse version order, so the most recent state is returned first. */ - var queryHistory by BOOLEAN_OR_FALSE + var queryHistory: Boolean by BOOLEAN_OR_FALSE /** * Defines how many states (versions) of each matching feature should be returned _(defaults to `1`)_. * - * A value of `1` means only the single latest state closest to the given maximal [version] is - * returned; if no [version] is given, the current HEAD state is meant. - * - * This parameter is ignored for queries by [Guid][naksha.model.Guid], because a - * [Guid][naksha.model.Guid] already identifies an exact state. The parameter requires - * [queryHistory] to be `true`. - * - * If set to anything other than `1` _(the default)_ while [queryHistory] is `false`, the request - * will be rejected with [ILLEGAL_ARGUMENT][naksha.model.NakshaError.ILLEGAL_ARGUMENT]. + * - A value of `1` _(the default)_ means only the single latest state closest to the given maximal [version] is returned. + * - If the underlying JSON map contains a values that is not a number or invalid, the default value `1` will be used. * * Requesting multiple versions can have a significant performance impact and should be used with care. * @since 3.0.0 */ - var versions by INT_OR_1 + var versions: Int + get() { + val raw = getRaw("versions") + if (raw is Int64) return max(1, raw.toInt()) + if (raw is Number) return max(1, raw.toInt()) + return 1 + } + set(value) { + if (value < 1) throw illegalArg("versions must not be a value less than 1") + set("versions", value) + } /** * Limit the read to all states at or after the given minimum version, `null` if no limit. * - * If set to anything other than `null` _(the default)_ while [queryHistory] is `false`, the request - * will be rejected with [ILLEGAL_ARGUMENT][naksha.model.NakshaError.ILLEGAL_ARGUMENT]. + * If the underlying JSON map contains a values that is not a number or invalid, the default value `null` will be used. * @since 3.0.0 */ - var minVersion by VERSION_OR_NULL + var minVersion: Int64? + get() { + val raw = getRaw("minVersion") + if (raw is Int64) return if (raw < Version.MIN.number || raw > Version.HEAD.number) null else raw + if (raw is Number) { + val value = Int64(raw.toLong()) + return if (value < Version.MIN.number || value > Version.HEAD.number) null else value + } + return null + } + set(value) { + if (value != null && (value < Version.MIN.number || value > Version.HEAD.number)) { + throw illegalArg("minVersion must be a value between ${Version.MIN} and ${Version.HEAD}, but was $value") + } + set("minVersion", value) + } + + @JsName("withMinVersionInt64") + fun withMinVersion(minVersion: Int64?): ReadFeatures { + this.minVersion = minVersion + return this + } + + @JsName("withMinVersion") + fun withMinVersion(minVersion: Version?): ReadFeatures { + this.minVersion = minVersion?.number + return this + } + + @JsName("withMinVersionLong") + fun withMinVersion(minVersion: Long?): ReadFeatures { + this.minVersion = if (minVersion != null) Int64(minVersion) else null + return this + } /** - * Limit the read to states at or before the given maximum version, `null` if no limit - * _(returns the current HEAD state)_. + * Limit the read to states at or before the given maximum version, `null` if no limit _([HEAD][naksha.model.Version.VersionCompanion.HEAD])_. * - * This effectively requests a specific historical snapshot when no [minVersion] is set and - * [versions] is `1` (the default). + * This effectively requests a specific historical snapshot, when no [minVersion] is set and [versions] is `1`, which is the default for both parameters. * - * If set to anything other than `null` _(the default)_ while [queryHistory] is `false`, the request - * will be rejected with [ILLEGAL_ARGUMENT][naksha.model.NakshaError.ILLEGAL_ARGUMENT]. + * If the underlying JSON map contains a values that is not a number or invalid, the default value `null` will be used. * @since 3.0.0 */ - var version by VERSION_OR_NULL + var version: Int64? + get() { + val raw = getRaw("version") + if (raw is Int64) return raw + if (raw is Number) return Int64(raw.toLong()) + return null + } + set(value) { + set("version", value) + } + + @JsName("withVersionInt64") + fun withVersion(version: Int64?): ReadFeatures { + this.version = version + return this + } + + @JsName("withVersion") + fun withVersion(version: Version?): ReadFeatures { + this.version = version?.number + return this + } + + @JsName("withVersionLong") + fun withVersion(version: Long?): ReadFeatures { + this.version = if (version != null) Int64(version) else null + return this + } + /** * Order the result-set like given; this is an expensive operation and should be avoided. * * If an order is required, but no specific one, then it is strongly recommended to stick with the [deterministic order][OrderBy.deterministic], which is produced by creating a blank empty [OrderBy] object or through the static helper method [OrderBy.deterministic]. Ordering by anything else can have a drastic performance impact. */ - var orderBy by ORDER_BY_OR_NULL + var orderBy: OrderBy? by ORDER_BY_OR_NULL /** * Add all features that match the given IDs into the result-set. * @since 3.0.0 */ - //TODO CASL-1149 should support custom queries - var featureIds by STRING_LIST + @Deprecated("Replaced with op", replaceWith = ReplaceWith("op")) + var featureIds: StringList by STRING_LIST /** * Add all features that match the given [GUIDs][naksha.model.Guid] into the result-set. @@ -202,13 +249,25 @@ open class ReadFeatures : ReadRequest() { * This can be used to load features in specific states. * @since 3.0.0 */ - var guids by GUID_LIST + // TODO: We should replace this with `tupleNumbers`, because that is what we will encode into `uuid` and that is what the clients need. + // Is there any use-case for the GUID any longer? + @Deprecated("Replace with load by tuple-number, should not be part of the query!", replaceWith = ReplaceWith("op")) + var guids: GuidList by GUID_LIST /** * Add all features that match the given query into the result-set. * @since 3.0.0 */ - var query by QUERY + @Deprecated("Replaced with op", replaceWith = ReplaceWith("op")) + var query: RequestQuery by QUERY + + /** + * The [operations][Op] to execute to query members. + * + * This replaces [query] and must not be used together with [query]. It actually allows to query for any member value. In doubt, [queryMembers] always wins. + * @since 3.0 + */ + var queryMembers: Op? by OP_OR_NULL /** * Tests whether this request is effectively a query for all features in their current **HEAD** state, diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadMaps.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadMaps.kt index 2b3f3dbd20..e414114292 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadMaps.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadMaps.kt @@ -10,7 +10,7 @@ import kotlin.js.JsExport import kotlin.js.JsName /** - * A request to read [map features][naksha.model.objects.NakshaMap] from a storage. + * A request to read [map features][naksha.model.objects.NakshaCatalog] from a storage. * @since 3.0 */ @JsExport @@ -64,15 +64,15 @@ open class ReadMaps() : ReadRequest() { /** * Convert this request into a [ReadFeatures] request. * - * Actually, reading maps is not different from reading features, because the storages will have a collection called `naksha~catalogs` in the admin-map in which the [map features][naksha.model.objects.NakshaMap] are stored, or at least the storage will simulate this virtual collection. + * Actually, reading maps is not different from reading features, because the storages will have a collection called `naksha~catalogs` in the admin-map in which the [map features][naksha.model.objects.NakshaCatalog] are stored, or at least the storage will simulate this virtual collection. * * This is necessary, if you want a more fine-grained query, like filter maps or request past states of the map feature. * @return this request as [ReadFeatures] request. */ fun toReadFeatures(): ReadFeatures { val req = ReadFeatures() - req.mapId = Naksha.ADMIN_MAP - req.collectionIds.add(Naksha.CATALOGS_COL) + req.catalogId = Naksha.ADMIN_CATALOG_ID + req.collectionId = Naksha.CATALOGS_COL_ID req.featureIds.addAll(mapIds) return req } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadRequest.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadRequest.kt index 5beecf1bdc..d93aa1ccf0 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadRequest.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadRequest.kt @@ -17,10 +17,8 @@ import kotlin.js.JsExport open class ReadRequest : Request() { companion object ReadRequestCompanion { private val INT_NULL = NullableProperty(Int::class) - private val BOOLEAN = - NullableProperty(Boolean::class) { _, _ -> false } - private val FETCH_MODE = - NotNullProperty(FetchMode::class) { _, _ -> FETCH_ALL } + private val BOOLEAN = NullableProperty(Boolean::class) { _, _ -> false } + private val FETCH_MODE = NotNullProperty(FetchMode::class) { _, _ -> FETCH_ALL } } override fun defaultRowOptions(): ReturnColumns = ReturnColumns.all() diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadTransactions.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadTransactions.kt index 5224afab33..864b1c042c 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadTransactions.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ReadTransactions.kt @@ -13,8 +13,8 @@ import kotlin.js.JsExport @JsExport open class ReadTransactions : ReadFeatures() { init { - mapId = Naksha.ADMIN_MAP - collectionIds.add(Naksha.TRANSACTIONS_COL) + catalogId = Naksha.ADMIN_CATALOG_ID + collectionId = Naksha.TRANSACTIONS_COL_ID } /** diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/RequestQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/RequestQuery.kt index 802dafeae4..5c838938cf 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/RequestQuery.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/RequestQuery.kt @@ -20,7 +20,7 @@ import kotlin.jvm.JvmField * - [naksha.model.request.query.SpOr] - logical OR for spatial conditions * - [naksha.model.request.query.TagOr] - logical OR for tag conditions * - [naksha.model.request.query.POr] - logical OR for property conditions - * - [naksha.model.request.query.MetaOr] - logical OR for metadata conditions + * - [naksha.model.request.query.MemberOr] - logical OR for metadata conditions * * @since 3.0 */ @@ -35,7 +35,7 @@ open class RequestQuery : AnyObject() { private val SPATIAL_QUERY_OR_NULL = NullableProperty(ISpatialQuery::class) private val TAG_QUERY_OR_NULL = NullableProperty(ITagQuery::class) private val PROPERTIES_QUERY_OR_NULL = NullableProperty(IPropertyQuery::class) - private val METADATA_QUERY_OR_NULL = NullableProperty(IMetaQuery::class) + private val MEMBER_QUERY_OR_NULL = NullableProperty(IMemberQuery::class) } /** @@ -43,6 +43,7 @@ open class RequestQuery : AnyObject() { * @since 3.0.0 * @see ISpatialQuery */ + @Deprecated("Use op queries, there can be multiple spatial members that can be searched and combined.") var spatial by SPATIAL_QUERY_OR_NULL /** @@ -50,6 +51,7 @@ open class RequestQuery : AnyObject() { * @since 3.0.0 * @see ITagQuery */ + @Deprecated("Use op queries, there can be multiple tag-like members that can be searched and combined.") var tags by TAG_QUERY_OR_NULL /** @@ -57,14 +59,16 @@ open class RequestQuery : AnyObject() { * @since 3.0.0 * @see IPropertyQuery */ + @Deprecated("Remove this completely, we only allow to actually search for members.") var properties by PROPERTIES_QUERY_OR_NULL /** - * Search for features matching the given metadata query. + * Search for features matching the given member query. * @since 3.0.0 - * @see IMetaQuery + * @see IMemberQuery */ - var metadata by METADATA_QUERY_OR_NULL + @Deprecated("Use op queries, meta has been removed.") + var members by MEMBER_QUERY_OR_NULL /** * Search for features that have a reference point in one of the given tiles. @@ -72,6 +76,7 @@ open class RequestQuery : AnyObject() { * If the list is empty, no limit is applied. * @since 3.0.0 */ + @Deprecated("Use op queries, there can be multiple refTiles-like members that can be searched and combined.") var refTiles by INT_LIST /** @@ -80,6 +85,7 @@ open class RequestQuery : AnyObject() { * @return this. * @since 3.0.0 */ + @Deprecated("Please use op queries instead") fun addRefTile(tile: HereTile): RequestQuery { refTiles.add(tile.intKey) return this @@ -91,6 +97,7 @@ open class RequestQuery : AnyObject() { * @return this. * @since 3.0.0 */ + @Deprecated("Please use op queries instead") fun removeRefTile(tile: HereTile): RequestQuery { refTiles.remove(tile.intKey) return this @@ -105,6 +112,6 @@ open class RequestQuery : AnyObject() { && spatial == null && tags == null && properties == null - && metadata == null + && members == null } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/SuccessResponse.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/SuccessResponse.kt index 55c987d6f6..5e1e3063bc 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/SuccessResponse.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/SuccessResponse.kt @@ -6,6 +6,7 @@ import naksha.geo.SpFeatureCollection import naksha.model.* import naksha.model.objects.NakshaFeature import naksha.model.objects.NakshaFeatureList +import naksha.model.objects.XyzMembers import kotlin.js.JsExport import kotlin.js.JsName import kotlin.jvm.JvmOverloads @@ -179,7 +180,12 @@ open class SuccessResponse() : Response() { list.setCapacity(featureList.size) for (feature in featureList) { if (feature == null) continue - list.add(FeatureTuple(feature)) + // TODO: We need to fix this, this is a very dirty hack, but we need to expose the tuple handling expilicty. + // So to say, lets remove the whole FeatureTuple context; a request returns a TupleNumberList. + // Then the user needs to turn this explicitly into a TupleList, and this into a FeatureList. + // We can offer a helper method that does all steps at ones, so turns a SuccessResponse into list of features. + // However, we want to expose the TupleNumber design explicitly. + list.add(FeatureTuple(feature, XyzMembers.XyzTn)) } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/Write.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/Write.kt index b8ffce9158..68e61643b5 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/Write.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/Write.kt @@ -4,18 +4,20 @@ package naksha.model.request import naksha.base.* import naksha.model.* -import naksha.model.Naksha.NakshaCompanion.ADMIN_MAP -import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL -import naksha.model.Naksha.NakshaCompanion.BOOKS_COL -import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL +import naksha.model.Naksha.NakshaCompanion.ADMIN_CATALOG_ID +import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL_ID +import naksha.model.Naksha.NakshaCompanion.BOOKS_COL_ID +import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_ID +import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL_ID import naksha.model.Naksha.NakshaCompanion.featureNumber -import naksha.model.Naksha.NakshaCompanion.isInternalId import naksha.model.Naksha.NakshaCompanion.partitionNumber import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE import naksha.model.objects.NakshaFeature import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaDictionary -import naksha.model.objects.NakshaMap +import naksha.model.objects.NakshaCatalog +import naksha.model.objects.StandardMembers +import naksha.model.objects.XyzMembers import kotlin.js.JsExport import kotlin.js.JsName import kotlin.js.JsStatic @@ -61,19 +63,19 @@ open class Write : AnyObject() { private fun compareMapIds(a: String, b: String): Int { if (a == b) return 0 // We order all modifications done in admin-map first. - if (ADMIN_MAP == a) return -1 - if (ADMIN_MAP == b) return 1 + if (ADMIN_CATALOG_ID == a) return -1 + if (ADMIN_CATALOG_ID == b) return 1 return a.compareTo(b) } private fun compareCollectionIds(a: String, b: String): Int { if (a == b) return 0 // We order all modifications done in map's collection first (create maps first). - if (CATALOGS_COL == a) return -1 - if (CATALOGS_COL == b) return 1 + if (CATALOGS_COL_ID == a) return -1 + if (CATALOGS_COL_ID == b) return 1 // We order all modifications done in internal collection's-collection second. - if (COLLECTIONS_COL == a) return -1 - if (COLLECTIONS_COL == b) return 1 + if (COLLECTIONS_COL_ID == a) return -1 + if (COLLECTIONS_COL_ID == b) return 1 // Rest by id return a.compareTo(b) } @@ -106,8 +108,8 @@ open class Write : AnyObject() { // Sorts by map-id, collection-id, feature-id. // Sorts admin-map, map's collection, and collection's collection first. - val a_mapId = a.mapId ?: throw illegalState("Write for feature '${a.id}' does not have 'mapId'") - val b_mapId = b.mapId ?: throw illegalState("Write for feature '${b.id}' does not have 'mapId'") + val a_mapId = a.catalogId ?: throw illegalState("Write for feature '${a.id}' does not have 'mapId'") + val b_mapId = b.catalogId ?: throw illegalState("Write for feature '${b.id}' does not have 'mapId'") val a_colId = a.collectionId ?: throw illegalState("Write for feature '${a.id}' does not have 'collectionId'") val b_colId = b.collectionId ?: throw illegalState("Write for feature '${b.id}' does not have 'collectionId'") val map_diff = compareMapIds(a_mapId, b_mapId) @@ -149,25 +151,25 @@ open class Write : AnyObject() { /** * The identifier of the map to access; if `null` then the map-id is read from the [NakshaContext]. * - * If a [map][NakshaMap] or [dictionary][NakshaDictionary] should be modified, then use [Naksha.ADMIN_MAP]. + * If a [map][NakshaCatalog] or [dictionary][NakshaDictionary] should be modified, then use [Naksha.ADMIN_CATALOG_ID]. * @since 3.0 */ - var mapId by MAP_ID + var catalogId by MAP_ID /** - * @see [mapId] + * @see [catalogId] */ fun withMapId(value: String?): Write { - mapId = value + catalogId = value return this } /** * The identifier of the collection to modify; must not be `null` then the map-id is read from the [NakshaContext]. * - * - If a [map][NakshaMap] should be modified, then [Naksha.CATALOGS_COL] should be used, within [Naksha.ADMIN_MAP]. - * - If a [dictionary][NakshaDictionary] should be modified, the [Naksha.BOOKS_COL] should be used, within [Naksha.ADMIN_MAP]. - * - If a [collection][NakshaCollection] should be modified, then [Naksha.COLLECTIONS_COL] should be used, must not be used together with [Naksha.ADMIN_MAP], because the admin-map does not allow collection modification, it is internally managed. + * - If a [map][NakshaCatalog] should be modified, then [Naksha.CATALOGS_COL_ID] should be used, within [Naksha.ADMIN_CATALOG_ID]. + * - If a [dictionary][NakshaDictionary] should be modified, the [Naksha.BOOKS_COL_ID] should be used, within [Naksha.ADMIN_CATALOG_ID]. + * - If a [collection][NakshaCollection] should be modified, then [Naksha.COLLECTIONS_COL_ID] should be used, must not be used together with [Naksha.ADMIN_CATALOG_ID], because the admin-map does not allow collection modification, it is internally managed. * - If a [feature][NakshaFeature] should be created, then the [NakshaCollection] in which the feature should be stored is required. * - Throws [ILLEGAL_STATE], if the collection-id is read, before being set. * @since 3.0 @@ -207,12 +209,13 @@ open class Write : AnyObject() { versionValue = Version(raw) return versionValue } - return feature?.properties?.xyz?.guid?.tupleNumber?.version + val versionNumber = feature?.properties?.xyz?.guid?.tupleNumber?.version + return if (versionNumber != null) Version(versionNumber) else null } set(value) { if (value == null) removeRaw("version") else { versionValue = value - versionRaw = value.txn + versionRaw = value.number setRaw("version", versionRaw) } } @@ -241,6 +244,7 @@ open class Write : AnyObject() { get() { val raw = getRaw("tupleNumber") if (raw is String) { + @Suppress("StringReferentialEquality") // Intentional reference compare ! if (raw === tupleNumberRaw) return tupleNumberValue tupleNumberValue = TupleNumber.fromUrn(raw) tupleNumberRaw = raw @@ -277,7 +281,7 @@ open class Write : AnyObject() { var atomic: Boolean get() { val raw = getRaw("atomic") - return if (raw is Boolean) raw else false + return raw as? Boolean ?: false } set(value) { if (value) setRaw("atomic", true) else removeRaw("atomic") @@ -347,12 +351,23 @@ open class Write : AnyObject() { val raw = getRaw("featureNumber") if (raw is Int64) return raw - val fn = feature?.featureNumber + // TODO: This is a hack, we need to change this. + // Without the collection, we normally do not know where the tuple-number is located within a feature. + val feature = this.feature + var fn: Int64? = null + if (feature != null) { + fn = XyzMembers.XyzTn.getTupleNumber(feature)?.featureNumber + if (fn == null) fn = StandardMembers.Tn.getTupleNumber(feature)?.featureNumber + } if (fn != null) return fn + // TODO: Eventually this boils down to how we handle collisions. In practise we rarely ever encountered a collision. + // It seems that a 63-bit hash is so unique, that the few collisions can be ignored and the burden can be given + // to the user to generate or use an alternative identifier, if two identifiers hash to the same number! val id = this.id val cachedId = featureNumberId val cachedNumber = featureNumberValue + @Suppress("StringReferentialEquality") // Intentional reference compare ! if (id === cachedId && cachedNumber != null) return cachedNumber val number = featureNumber(id) featureNumberId = id @@ -388,41 +403,6 @@ open class Write : AnyObject() { return this } - /** - * Arbitrary attachment to be stored, if this is [CREATE][WriteOp.CREATE], [UPSERT][WriteOp.UPSERT], or [UPDATE][WriteOp.UPDATE]. - * - * If being [UNDEFINED], then the attachment, in whatever state it is, is left unmodified, _(this is the default value)_. If the value is explicitly set to `null`, an existing attachments is removed, if set to a specific byte-array, then the attachment is updated. - * - * If this is a [CREATE][WriteOp.CREATE] operation, the value [UNDEFINED] has the same meaning as explicitly setting the value to `null`. - * @since 3.0 - * @see [UNDEFINED] - */ - var attachment: ByteArray? = UNDEFINED - - /** - * @see [attachment] - */ - fun withAttachment(value: ByteArray?): Write { - attachment = value - return this - } - - /** - * Ask the storage to keep the attachment in the state in which it currently is. This is the default behavior. - * @return this. - * @since 3.0 - */ - fun keepAttachment(): Write { - attachment = UNDEFINED - return this - } - - /** - * Tests if the attachment should be modified. - * @return `true` if the attachment should be modified; `false` if the attachment should stay unchanged _(default behavior)_. - */ - fun attachmentModified(): Boolean = attachment !== UNDEFINED - /** * If enabled, a missing map is automatically created, when creating or modifying collections; defaults to `false`. * @@ -471,8 +451,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun createDictionary(dict: NakshaDictionary): Write { - this.mapId = ADMIN_MAP - this.collectionId = BOOKS_COL + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = BOOKS_COL_ID this.op = WriteOp.CREATE this.feature = dict return this @@ -486,8 +466,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun updateDictionary(dict: NakshaDictionary, atomic: Boolean): Write { - this.mapId = ADMIN_MAP - this.collectionId = BOOKS_COL + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = BOOKS_COL_ID this.op = WriteOp.UPDATE this.feature = dict this.atomic = atomic @@ -501,8 +481,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun upsertDictionary(dict: NakshaDictionary): Write { - this.mapId = ADMIN_MAP - this.collectionId = BOOKS_COL + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = BOOKS_COL_ID this.op = WriteOp.UPSERT this.feature = dict return this @@ -516,8 +496,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun deleteDictionary(dict: NakshaDictionary, atomic: Boolean): Write { - this.mapId = ADMIN_MAP - this.collectionId = BOOKS_COL + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = BOOKS_COL_ID this.op = WriteOp.DELETE this.feature = dict this.atomic = atomic @@ -533,8 +513,8 @@ open class Write : AnyObject() { */ @JvmOverloads fun deleteDictionaryById(dictId: String, version: Version? = null): Write { - this.mapId = ADMIN_MAP - this.collectionId = BOOKS_COL + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = BOOKS_COL_ID this.op = WriteOp.DELETE this.id = dictId this.version = version @@ -548,9 +528,9 @@ open class Write : AnyObject() { * @return this. * @since 3.0 */ - fun createMap(map: NakshaMap): Write { - this.mapId = ADMIN_MAP - this.collectionId = CATALOGS_COL + fun createMap(map: NakshaCatalog): Write { + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = CATALOGS_COL_ID this.op = WriteOp.CREATE this.feature = map return this @@ -563,9 +543,9 @@ open class Write : AnyObject() { * @return this. * @since 3.0 */ - fun updateMap(map: NakshaMap, atomic: Boolean): Write { - this.mapId = ADMIN_MAP - this.collectionId = CATALOGS_COL + fun updateMap(map: NakshaCatalog, atomic: Boolean): Write { + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = CATALOGS_COL_ID this.op = WriteOp.UPDATE this.feature = map this.atomic = atomic @@ -579,9 +559,9 @@ open class Write : AnyObject() { * @return this. * @since 3.0 */ - fun upsertMap(map: NakshaMap, atomic: Boolean): Write { - this.mapId = ADMIN_MAP - this.collectionId = CATALOGS_COL + fun upsertMap(map: NakshaCatalog, atomic: Boolean): Write { + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = CATALOGS_COL_ID this.op = WriteOp.UPSERT this.feature = map this.atomic = atomic @@ -595,9 +575,9 @@ open class Write : AnyObject() { * @return this. * @since 3.0 */ - fun deleteMap(map: NakshaMap, atomic: Boolean): Write { - this.mapId = ADMIN_MAP - this.collectionId = CATALOGS_COL + fun deleteMap(map: NakshaCatalog, atomic: Boolean): Write { + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = CATALOGS_COL_ID this.op = WriteOp.DELETE this.feature = map this.atomic = atomic @@ -613,8 +593,8 @@ open class Write : AnyObject() { */ @JvmOverloads fun deleteMapById(id: String, version: Version? = null): Write { - this.mapId = ADMIN_MAP - this.collectionId = CATALOGS_COL + this.catalogId = ADMIN_CATALOG_ID + this.collectionId = CATALOGS_COL_ID this.op = WriteOp.DELETE this.id = id this.version = version @@ -628,8 +608,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun createCollection(collection: NakshaCollection): Write { - this.mapId = collection.mapId - this.collectionId = COLLECTIONS_COL + this.catalogId = collection.catalogId + this.collectionId = COLLECTIONS_COL_ID this.op = WriteOp.CREATE this.feature = collection return this @@ -642,8 +622,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun updateCollection(collection: NakshaCollection, atomic: Boolean): Write { - this.mapId = collection.mapId - this.collectionId = COLLECTIONS_COL + this.catalogId = collection.catalogId + this.collectionId = COLLECTIONS_COL_ID this.op = WriteOp.UPDATE this.feature = collection this.atomic = atomic @@ -656,8 +636,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun upsertCollection(collection: NakshaCollection): Write { - this.mapId = collection.mapId - this.collectionId = COLLECTIONS_COL + this.catalogId = collection.catalogId + this.collectionId = COLLECTIONS_COL_ID this.op = WriteOp.UPSERT this.feature = collection return this @@ -670,8 +650,8 @@ open class Write : AnyObject() { * @since 3.0 */ fun deleteCollection(collection: NakshaCollection, atomic: Boolean): Write { - this.mapId = collection.mapId - this.collectionId = COLLECTIONS_COL + this.catalogId = collection.catalogId + this.collectionId = COLLECTIONS_COL_ID this.op = WriteOp.DELETE this.feature = collection this.atomic = atomic @@ -687,8 +667,8 @@ open class Write : AnyObject() { */ @JvmOverloads fun deleteCollectionById(mapId: String? = null, collectionId: String, version: Version? = null): Write { - this.mapId = mapId - this.collectionId = COLLECTIONS_COL + this.catalogId = mapId + this.collectionId = COLLECTIONS_COL_ID this.op = WriteOp.DELETE this.id = collectionId this.version = version @@ -704,7 +684,7 @@ open class Write : AnyObject() { * @since 3.0 */ fun createFeature(collection: NakshaCollection, feature: NakshaFeature): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.CREATE this.feature = feature @@ -722,7 +702,7 @@ open class Write : AnyObject() { @JsName("createFeatureUsingIds") @JvmOverloads fun createFeature(mapId: String? = null, collectionId: String, feature: NakshaFeature): Write { - this.mapId = mapId + this.catalogId = mapId this.collectionId = collectionId this.op = WriteOp.CREATE this.feature = feature @@ -737,7 +717,7 @@ open class Write : AnyObject() { * @since 3.0 */ fun updateFeature(collection: NakshaCollection, feature: NakshaFeature, atomic: Boolean): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.UPDATE this.feature = feature @@ -756,7 +736,7 @@ open class Write : AnyObject() { @JsName("updateFeatureUsingIds") @JvmOverloads fun updateFeature(mapId: String? = null, collectionId: String, feature: NakshaFeature, atomic: Boolean): Write { - this.mapId = mapId + this.catalogId = mapId this.collectionId = collectionId this.op = WriteOp.UPDATE this.feature = feature @@ -771,7 +751,7 @@ open class Write : AnyObject() { * @since 3.0 */ fun upsertFeature(collection: NakshaCollection, feature: NakshaFeature): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.UPSERT this.feature = feature @@ -788,7 +768,7 @@ open class Write : AnyObject() { @JsName("upsertFeatureUsingIds") @JvmOverloads fun upsertFeature(mapId: String? = null, collectionId: String, feature: NakshaFeature): Write { - this.mapId = mapId + this.catalogId = mapId this.collectionId = collectionId this.op = WriteOp.UPSERT this.feature = feature @@ -803,7 +783,7 @@ open class Write : AnyObject() { * @since 3.0 */ fun deleteFeature(collection: NakshaCollection, feature: NakshaFeature, atomic: Boolean): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.DELETE this.feature = feature @@ -821,7 +801,7 @@ open class Write : AnyObject() { */ @JvmOverloads fun deleteFeatureById(collection: NakshaCollection, id: String, version: Version? = null): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.DELETE this.id = id @@ -842,7 +822,7 @@ open class Write : AnyObject() { @JsName("deleteFeatureByIds") @JvmOverloads fun deleteFeatureById(mapId: String? = null, collectionId: String, id: String, version: Version? = null): Write { - this.mapId = mapId + this.catalogId = mapId this.collectionId = collectionId this.op = WriteOp.DELETE this.id = id @@ -859,7 +839,7 @@ open class Write : AnyObject() { * @since 3.0 */ fun purgeFeature(collection: NakshaCollection, feature: NakshaFeature, atomic: Boolean): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.PURGE this.feature = feature @@ -878,7 +858,7 @@ open class Write : AnyObject() { @JsName("purgeFeatureById") @JvmOverloads fun purgeFeatureById(collection: NakshaCollection, id: String, version: Version? = null): Write { - this.mapId = collection.mapId + this.catalogId = collection.catalogId this.collectionId = collection.id this.op = WriteOp.PURGE this.id = id @@ -899,7 +879,7 @@ open class Write : AnyObject() { @JsName("purgeFeatureByIds") @JvmOverloads fun purgeFeatureById(mapId: String? = null, collectionId: String, id: String, version: Version? = null): Write { - this.mapId = mapId + this.catalogId = mapId this.collectionId = collectionId this.op = WriteOp.PURGE this.id = id @@ -914,7 +894,7 @@ open class Write : AnyObject() { * @return `true` if this write modifies a dictionary; `false` otherwise. * @since 3.0 */ - fun isDictionaryModification(): Boolean = mapId == ADMIN_MAP && collectionId == BOOKS_COL + fun isDictionaryModification(): Boolean = catalogId == ADMIN_CATALOG_ID && collectionId == BOOKS_COL_ID /** * Tests if this write modifies a map. @@ -922,7 +902,7 @@ open class Write : AnyObject() { * @return `true` if this write modifies a map; `false` otherwise. * @since 3.0 */ - fun isMapModification(): Boolean = mapId == ADMIN_MAP && collectionId == CATALOGS_COL + fun isMapModification(): Boolean = catalogId == ADMIN_CATALOG_ID && collectionId == CATALOGS_COL_ID /** * Tests if this write modifies a collection. @@ -930,7 +910,7 @@ open class Write : AnyObject() { * @return `true` if this write modifies a collection; `false` otherwise. * @since 3.0 */ - fun isCollectionModification(): Boolean = collectionId == COLLECTIONS_COL + fun isCollectionModification(): Boolean = collectionId == COLLECTIONS_COL_ID /** * Tests if this write modifies a feature within a collection. @@ -953,14 +933,24 @@ open class Write : AnyObject() { * @see [WriteOp] */ fun validate(): Write { - if (mapId == ADMIN_MAP || collectionId == COLLECTIONS_COL) { - if (isInternalId(id)) { - throw NakshaException(ILLEGAL_STATE, "Modification of internal features forbidden: '$id'") + // Writing into the admin-catalog means that we either want to mutate administrative objects. + if (catalogId == ADMIN_CATALOG_ID) { + if (collectionId == CATALOGS_COL_ID) { // Mutation of catalog. + NakshaIdType.CATALOG.verify(id) + } else if (collectionId == BOOKS_COL_ID) { // Mutation of book. + NakshaIdType.BOOK.verify(id) + } else if (collectionId == COLLECTIONS_COL_ID) { // Mutation of collection. + NakshaIdType.COLLECTION.verify(id) + } else if (collectionId == TRANSACTIONS_COL_ID) { // Mutation of transaction. + NakshaIdType.TRANSACTION.verify(id) + } else { + throw illegalArg("Write operation for invalid collection in admin catalog; id: '$id' ") } + return this } - if (!Naksha.isValidId(id)) { - throw NakshaException(ILLEGAL_STATE, "Invalid feature-id: '$id'") - } + if (collectionId == COLLECTIONS_COL_ID) { // Mutation of collection in custom catalog. + NakshaIdType.COLLECTION.verify(id) + } // otherwise, arbitrary feature is modified, we allow any identifier. return this } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/WriteOp.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/WriteOp.kt index b1cdac850b..b970220f62 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/WriteOp.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/WriteOp.kt @@ -19,45 +19,45 @@ class WriteOp : JsEnum(), Comparable { val NULL = def(WriteOp::class, null) /** - * Create the feature, fail if the feature exists already. + * Delete the feature, does not fail normally, even when the feature does not exist. */ @JvmField @JsStatic - val CREATE = defIgnoreCase(WriteOp::class, "CREATE") { self -> self.order = 0 } + val DELETE = defIgnoreCase(WriteOp::class, "DELETE") { self -> self.order = 0 } /** - * Update or created the feature, should never fail. + * Delete the feature, and remove remainders from the shadow delete table, so delete fully. + * + * This operation is important for _views_, where a _PURGE_ will restore the underlying version of a feature from lower layers, while a normal _DELETE_ would cause the feature to disappear. It is strongly recommended to only execute a _PURGE_, when being in a view context and the behavior that results from it is explicitly wished. */ @JvmField @JsStatic - val UPSERT = defIgnoreCase(WriteOp::class, "UPSERT") { self -> self.order = 1 } + val PURGE = defIgnoreCase(WriteOp::class, "PURGE") { self -> self.order = 1 } /** - * Update the feature, fail if the feature does not exist. + * Create the feature, fail if the feature exists already. */ @JvmField @JsStatic - val UPDATE = defIgnoreCase(WriteOp::class, "UPDATE") { self -> self.order = 2 } + val CREATE = defIgnoreCase(WriteOp::class, "CREATE") { self -> self.order = 2 } /** - * Delete the feature, does not fail normally, even when the feature does not exist. + * Update or created the feature, should never fail. */ @JvmField @JsStatic - val DELETE = defIgnoreCase(WriteOp::class, "DELETE") { self -> self.order = 3 } + val UPSERT = defIgnoreCase(WriteOp::class, "UPSERT") { self -> self.order = 3 } /** - * Delete the feature, and remove remainders from the shadow delete table, so delete fully. - * - * This operation is important for _views_, where a _PURGE_ will restore the underlying version of a feature from lower layers, while a normal _DELETE_ would cause the feature to disappear. It is strongly recommended to only execute a _PURGE_, when being in a view context and the behavior that results from it is explicitly wished. + * Update the feature, fail if the feature does not exist. */ @JvmField @JsStatic - val PURGE = defIgnoreCase(WriteOp::class, "PURGE") { self -> self.order = 4 } + val UPDATE = defIgnoreCase(WriteOp::class, "UPDATE") { self -> self.order = 4 } } /** - * An ordering number, defaults to 100 (so order at the end). + * An ordering number of the operation. Logically, we first want to `DELETE`, then `PURGE`, then `CREATE`, then `UPSERT`, and finally `UPDATE`. The `PURGE` simply copies the current feature state from _HEAD_ into _HISTORY_, setting the next-version. It can be an explicit operation, but mostly will be an intrinsic operation of `CREATE`, `UPSERT`, or `UPDATE`. */ var order: Int = 100 private set diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/And.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/And.kt new file mode 100644 index 0000000000..625474b369 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/And.kt @@ -0,0 +1,31 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Logical AND. + * @since 3.0 + */ +@JsExport +class And() : Op() { + companion object And_C { + private val VALUES = NotNullProperty(OpList::class) { _, _ -> OpList() } + } + + @JsName("of") + constructor(vararg children: Op) : this() { + this.op = AND + val _children = this.children + for (arg in children) _children.add(arg) + } + + /** + * The operation arguments, so the sub-operations to logically AND combine. + * @since 3.0 + */ + var children: OpList by VALUES +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Equals.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Equals.kt new file mode 100644 index 0000000000..ddf3854cec --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Equals.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] equals the given [value]. + * @since 3.0 + */ +@JsExport +class Equals() : Op() { + companion object Equals_C { + private val VALUE = NullableProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, value: Any?) : this() { + this.op = EQ + this.at = at + this.value = value + } + + @JsName("forMember") + constructor(at: Member, value: Any?) : this(at.name, value) + + /** + * The value to compare against. + * @since 3.0 + */ + var value: Any? by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Gt.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Gt.kt new file mode 100644 index 0000000000..b64ecd8ca6 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Gt.kt @@ -0,0 +1,32 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is greater than the given [value] (GreaterThan). + * @since 3.0 + */ +@JsExport +class Gt() : Op() { + companion object Gt_C { + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, value: Any) : this() { + this.op = GT + this.at = at + this.value = value + } + + @JsName("forMember") + constructor(at: Member, value: Any) : this(at.name, value) + + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Gte.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Gte.kt new file mode 100644 index 0000000000..e58452914b --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Gte.kt @@ -0,0 +1,32 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is greater than or equal to the given [value] (GreaterThanOrEqual). + * @since 3.0 + */ +@JsExport +class Gte() : Op() { + companion object Gte_C { + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, value: Any) : this() { + this.op = GTE + this.at = at + this.value = value + } + + @JsName("forMember") + constructor(at: Member, value: Any) : this(at.name, value) + + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IOp.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IOp.kt new file mode 100644 index 0000000000..74b546eb86 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IOp.kt @@ -0,0 +1,12 @@ +@file:OptIn(ExperimentalJsExport::class) + +package naksha.model.request.ops + +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Marker interface added to all real operation. + */ +@JsExport +interface IOp diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Intersects.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Intersects.kt new file mode 100644 index 0000000000..385c577c9c --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Intersects.kt @@ -0,0 +1,45 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.geo.SpGeometry +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the geometry member at [at] intersects with the given [value] geometry. + * @since 3.0 + */ +@JsExport +class Intersects() : Op() { + companion object Intersects_C { + private val VALUE = NotNullProperty(SpGeometry::class) { _,_ -> SpGeometry() } + private val TRANSFORMERS = NotNullProperty(SpTransformationList::class) { _,_ -> SpTransformationList() } + } + + @JsName("forName") + constructor(at: String, geometry: SpGeometry, vararg transformers: SpTransformation) : this() { + this.op = INTERSECTS + this.at = at + this.value = geometry + val _transformers = this.transformers + for (t in transformers) _transformers.add(t) + } + + @JsName("forMember") + constructor(at: Member, geometry: SpGeometry, vararg transformers: SpTransformation) : this(at.name, geometry, *transformers) + + /** + * The geometry to test for intersection. + * @since 3.0 + */ + var value: SpGeometry by VALUE + + /** + * Optional transformations to apply to the geometry. + * @since 3.0 + */ + var transformers: SpTransformationList by TRANSFORMERS +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsAnyOf.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsAnyOf.kt new file mode 100644 index 0000000000..bbd086e5c8 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsAnyOf.kt @@ -0,0 +1,33 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.AnyList +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is any of the given [items]. + * @since 3.0 + */ +@JsExport +class IsAnyOf() : Op() { + companion object IsAnyOf_C { + private val ITEMS = NotNullProperty(AnyList::class) { _,_ -> AnyList() } + } + + @JsName("forName") + constructor(at: String, vararg items: Any) : this() { + this.op = IS_ANY_OF + this.at = at + val _items = this.items + for (item in items) _items.add(item) + } + + @JsName("forMember") + constructor(at: Member, vararg items: Any) : this(at.name, *items) + + var items: AnyList by ITEMS +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsFalse.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsFalse.kt new file mode 100644 index 0000000000..567605d703 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsFalse.kt @@ -0,0 +1,23 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is false. + * @since 3.0 + */ +@JsExport +class IsFalse() : Op() { + @JsName("forName") + constructor(at: String) : this() { + this.op = IS_FALSE + this.at = at + } + + @JsName("forMember") + constructor(at: Member) : this(at.name) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsNull.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsNull.kt new file mode 100644 index 0000000000..6d99ba2c5d --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsNull.kt @@ -0,0 +1,23 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is null. + * @since 3.0 + */ +@JsExport +class IsNull() : Op() { + @JsName("forName") + constructor(at: String) : this() { + this.op = IS_NULL + this.at = at + } + + @JsName("forMember") + constructor(at: Member) : this(at.name) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsTrue.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsTrue.kt new file mode 100644 index 0000000000..93dedc3962 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/IsTrue.kt @@ -0,0 +1,23 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is true. + * @since 3.0 + */ +@JsExport +class IsTrue() : Op() { + @JsName("forName") + constructor(at: String) : this() { + this.op = IS_TRUE + this.at = at + } + + @JsName("forMember") + constructor(at: Member) : this(at.name) +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Lt.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Lt.kt new file mode 100644 index 0000000000..84f896f8d3 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Lt.kt @@ -0,0 +1,32 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is less than the given [value] (LessThan). + * @since 3.0 + */ +@JsExport +class Lt() : Op() { + companion object Lt_C { + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, value: Any) : this() { + this.op = LT + this.at = at + this.value = value + } + + @JsName("forMember") + constructor(at: Member, value: Any) : this(at.name, value) + + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Lte.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Lte.kt new file mode 100644 index 0000000000..61b7e470be --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Lte.kt @@ -0,0 +1,32 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] is less than or equal to the given [value] (LessThanOrEqual). + * @since 3.0 + */ +@JsExport +class Lte() : Op() { + companion object Lte_C { + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, value: Any) : this() { + this.op = LTE + this.at = at + this.value = value + } + + @JsName("forMember") + constructor(at: Member, value: Any) : this(at.name, value) + + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Not.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Not.kt new file mode 100644 index 0000000000..6936c6d0fd --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Not.kt @@ -0,0 +1,31 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Logical NOT. + * @since 3.0 + */ +@JsExport +class Not() : Op() { + companion object Not_C { + private val CHILD = NotNullProperty(Op::class) + } + + @JsName("of") + constructor(child: Op) : this() { + this.op = NOT + this.child = child + } + + /** + * The operation argument, so the sub-operation to logically NOT. + * @since 3.0 + */ + var child: Op by CHILD +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Op.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Op.kt new file mode 100644 index 0000000000..0cd9053af7 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Op.kt @@ -0,0 +1,108 @@ +@file:OptIn(ExperimentalJsExport::class, ExperimentalJsStatic::class) + +package naksha.model.request.ops + +import naksha.base.AnyObject +import naksha.base.MapProxy +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import kotlin.js.ExperimentalJsExport +import kotlin.js.ExperimentalJsStatic +import kotlin.js.JsExport +import kotlin.js.JsStatic +import kotlin.jvm.JvmStatic + +/** + * A member operation. + * @since 3.0 + */ +@JsExport +open class Op : AnyObject() { + companion object MemberOp_C { + private val STRING = NotNullProperty(String::class) + private val STRING_OR_NULL = NullableProperty(String::class) + + const val AND = "and" + const val OR = "or" + const val NOT = "not" + const val IS_NULL = "is_null" + const val IS_TRUE = "is_true" + const val IS_FALSE = "is_false" + const val EQ = "eq" + const val GT = "gt" + const val GTE = "gte" + const val LT = "lt" + const val LTE = "lte" + const val STARTS_WITH = "starts_with" + const val IS_ANY_OF = "any_of" + const val INTERSECTS = "intersects" + const val TAGMAP_HAS_KEY = "tagmap_has_key" + const val TAGMAP_HAS_ANY_OF = "tagmap_has_any_of" + const val TAGMAP_HAS_ALL_OF = "tagmap_has_all_of" + const val TAG_IS_NULL = "tag_is_null" + const val TAG_EQ = "tag_eq" + const val TAG_GT = "tag_gt" + const val TAG_GTE = "tag_gte" + const val TAG_LT = "tag_lt" + const val TAG_LTE = "tag_lte" + const val TAG_STARTS_WITH = "tag_starts_with" + const val TAG_MATCHES = "tag_matches" + @Suppress("SpellCheckingInspection") + const val TAGLIST_CONTAINS = "taglist_contains" + @Suppress("SpellCheckingInspection") + const val TAGLIST_CONTAINS_ANY_OF = "taglist_contains_any_of" + @Suppress("SpellCheckingInspection") + const val TAGLIST_CONTAINS_ALL_OF = "taglist_contains_all_of" + + /** + * Auto-detect the concrete type of member operation and return the cast real type. + * @param op the member-operation to detect the real type. + * @return the real [Op] instance or just `null`, if no real type is known. + */ + @JvmStatic + @JsStatic + fun detect(op: MapProxy<*,*>): Op? { + if (op is Op && op::class != Op::class) return op + return when(op.getRaw("op") as String?) { + AND -> op.proxy(And::class) + OR -> op.proxy(Or::class) + NOT -> op.proxy(Not::class) + IS_NULL -> op.proxy(IsNull::class) + IS_TRUE -> op.proxy(IsTrue::class) + IS_FALSE -> op.proxy(IsFalse::class) + EQ -> op.proxy(Equals::class) + GT -> op.proxy(Gt::class) + GTE -> op.proxy(Gte::class) + LT -> op.proxy(Lt::class) + LTE -> op.proxy(Lte::class) + STARTS_WITH -> op.proxy(StartsWith::class) + IS_ANY_OF -> op.proxy(IsAnyOf::class) + INTERSECTS -> op.proxy(Intersects::class) + TAGMAP_HAS_KEY -> op.proxy(TagMapHasKey::class) + TAGMAP_HAS_ANY_OF -> op.proxy(TagMapHasAnyOf::class) + TAGMAP_HAS_ALL_OF -> op.proxy(TagMapHasAllOf::class) + TAG_IS_NULL -> op.proxy(TagIsNull::class) + TAG_EQ -> op.proxy(TagEquals::class) + TAG_STARTS_WITH -> op.proxy(TagStartsWith::class) + TAG_MATCHES -> op.proxy(TagMatches::class) + TAG_GT -> op.proxy(TagGt::class) + TAG_GTE -> op.proxy(TagGte::class) + TAG_LT -> op.proxy(TagLt::class) + TAG_LTE -> op.proxy(TagLte::class) + TAGLIST_CONTAINS_ANY_OF -> op.proxy(TagListContainsAnyOf::class) + TAGLIST_CONTAINS_ALL_OF -> op.proxy(TagListContainsAllOf::class) + else -> null + } + } + } + + /** + * The operation identifier. + */ + var op: String by STRING + + /** + * The name of the member to query; if any _(some operations do not work upon members)_. + */ + var at: String? by STRING_OR_NULL +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/OpList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/OpList.kt new file mode 100644 index 0000000000..080600d11c --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/OpList.kt @@ -0,0 +1,10 @@ +@file:OptIn(ExperimentalJsExport::class) + +package naksha.model.request.ops + +import naksha.base.ListProxy +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +@JsExport +class OpList : ListProxy(Op::class) \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Or.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Or.kt new file mode 100644 index 0000000000..9275605184 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/Or.kt @@ -0,0 +1,31 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Logical OR. + * @since 3.0 + */ +@JsExport +class Or() : Op() { + companion object Or_C { + private val VALUES = NotNullProperty(OpList::class) { _, _ -> OpList() } + } + + @JsName("of") + constructor(vararg children: Op) : this() { + this.op = OR + val _children = this.children + for (arg in children) _children.add(arg) + } + + /** + * The operation arguments, so the sub-operations to logically OR combine. + * @since 3.0 + */ + var children: OpList by VALUES +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/QueryConverter.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/QueryConverter.kt new file mode 100644 index 0000000000..13dd2589c8 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/QueryConverter.kt @@ -0,0 +1,283 @@ +@file:Suppress("OPT_IN_USAGE", "DEPRECATION") + +package naksha.model.request.ops + +import naksha.base.IntList +import naksha.base.JsEnum +import naksha.geo.HereTile +import naksha.model.NakshaError.NakshaErrorCompanion.UNSUPPORTED_OPERATION +import naksha.model.NakshaException +import naksha.model.illegalArg +import naksha.model.objects.Member +import naksha.model.objects.XyzMembers +import naksha.model.request.RequestQuery +import naksha.model.request.query.AnyOp +import naksha.model.request.query.DoubleOp +import naksha.model.request.query.IMemberQuery +import naksha.model.request.query.ISpatialQuery +import naksha.model.request.query.ITagQuery +import naksha.model.request.query.MemberAnd +import naksha.model.request.query.MemberNot +import naksha.model.request.query.MemberOr +import naksha.model.request.query.MemberQuery +import naksha.model.request.query.SpAnd +import naksha.model.request.query.SpIntersects +import naksha.model.request.query.SpNot +import naksha.model.request.query.SpOr +import naksha.model.request.query.SpRefInHereTile +import naksha.model.request.query.StringOp +import naksha.model.request.query.TagAnd +import naksha.model.request.query.TagExists +import naksha.model.request.query.TagNot +import naksha.model.request.query.TagOr +import naksha.model.request.query.TagQuery +import naksha.model.request.query.TagSetContains +import naksha.model.request.query.TagValueIsBool +import naksha.model.request.query.TagValueIsDouble +import naksha.model.request.query.TagValueIsNull +import naksha.model.request.query.TagValueIsString +import naksha.model.request.query.TagValueMatches +import kotlin.js.JsExport +import naksha.model.request.query.SpBuffer as QuerySpBuffer +import naksha.model.request.query.SpTransformation as QuerySpTransformation + +/** + * Converts the deprecated, fixed-column XYZ queries _([RequestQuery])_ into the generalized, + * member-based [operations][Op]. + * + * @since 3.0 + */ +@JsExport +class QueryConverter private constructor() { + companion object PgQueryConverter_C { + /** + * Convert the given [request-query][RequestQuery] into a single [operation][Op]. + * + * @param query the deprecated request-query to convert. + * @return the equivalent [operation][Op]; or `null`, if there is nothing to convert. + * @throws NakshaException with [UNSUPPORTED_OPERATION], if a query has no generalized equivalent. + * @since 3.0 + */ + fun convert(query: RequestQuery): Op? { + val ops = ArrayList() + query.tags?.let { ops.add(convertTag(it)) } + query.spatial?.let { ops.add(convertSpatial(it)) } + query.members?.let { ops.add(convertMember(it)) } + convertRefTiles(query.refTiles)?.let { ops.add(it) } + return when (ops.size) { + 0 -> null + 1 -> ops[0] + else -> And(*ops.toTypedArray()) + } + } + + // --------------------------------------------------------< tags >--------------------------------------------------------- + + /** + * Convert a single _(possibly nested)_ [tag-query][ITagQuery] into an [operation][Op]. + * + */ + private fun convertTag(query: ITagQuery): Op = when (query) { + // Logical combinators become their generalized counterparts. + is TagAnd -> And(*query.filterNotNull().map { convertTag(it) }.toTypedArray()) + is TagOr -> Or(*query.filterNotNull().map { convertTag(it) }.toTypedArray()) + is TagNot -> Not(convertTag(query.query)) + + // Element containment in a tag-list. + is TagSetContains -> { + val element = query.element ?: throw NakshaException( + UNSUPPORTED_OPERATION, "TagSetContains without an element can't be converted" + ) + TagListContains(XyzMembers.XyzTags, element) + } + + // TagExists just tests for the existence of the key/element, ignoring the value. + is TagExists -> TagMapHasKey(XyzMembers.XyzTags, query.name) + + // TagValueIsNull tests that the key exists and is explicitly assigned the value `null`. + is TagValueIsNull -> TagIsNull(XyzMembers.XyzTags, query.name) + + is TagValueIsBool -> TagEquals(XyzMembers.XyzTags, query.name, query.value) + is TagValueIsString -> TagEquals(XyzMembers.XyzTags, query.name, query.value) + is TagValueIsDouble -> convertTagValueIsDouble(query) + + // Regular-expression matching on the tag value. + is TagValueMatches -> TagMatches(XyzMembers.XyzTags, query.name, query.regex) + + is TagQuery -> throw NakshaException( + UNSUPPORTED_OPERATION, "Can't convert tag-query of type '${query::class.simpleName}'" + ) + else -> throw NakshaException( + UNSUPPORTED_OPERATION, "Can't convert tag-query of type '${query::class.simpleName}'" + ) + } + + /** + * Convert a [TagValueIsDouble] into the matching tag operation, depending on its [operation][DoubleOp]. + */ + private fun convertTagValueIsDouble(query: TagValueIsDouble): Op { + val tags = XyzMembers.XyzTags + val name = query.name + val value = query.value + return when (query.op) { + DoubleOp.EQ -> TagEquals(tags, name, value) + DoubleOp.NE -> Not(TagEquals(tags, name, value)) + DoubleOp.GT -> TagGt(tags, name, value) + DoubleOp.GTE -> TagGte(tags, name, value) + DoubleOp.LT -> TagLt(tags, name, value) + DoubleOp.LTE -> TagLte(tags, name, value) + else -> throw NakshaException( + UNSUPPORTED_OPERATION, "Unsupported double operation '${query.op}' in TagValueIsDouble" + ) + } + } + + // -------------------------------------------------------< spatial >------------------------------------------------------- + + /** + * Convert a single _(possibly nested)_ [spatial-query][ISpatialQuery] into an [operation][Op]. + */ + private fun convertSpatial(query: ISpatialQuery): Op = when (query) { + is SpAnd -> And(*query.filterNotNull().map { convertSpatial(it) }.toTypedArray()) + is SpOr -> Or(*query.filterNotNull().map { convertSpatial(it) }.toTypedArray()) + is SpNot -> Not(convertSpatial(query.query)) + is SpIntersects -> { + val transformers = flattenTransformation(query.transformation) + Intersects(XyzMembers.XyzGeometry, query.geometry, *transformers.toTypedArray()) + } + is SpRefInHereTile -> tileRange(query.getHereTile()) + else -> throw NakshaException( + UNSUPPORTED_OPERATION, "Can't convert spatial-query of type '${query::class.simpleName}'" + ) + } + + /** + * Flatten the old single-link transformation chain _(each transformation has an optional + * [child][QuerySpTransformation.childTransformation] executed before it)_ into the ordered list of + * generalized [transformations][SpTransformation] expected by [Intersects], deepest child first. + */ + private fun flattenTransformation(transformation: QuerySpTransformation?): List { + val chain = ArrayList() + var current = transformation + while (current != null) { + chain.add(current) + current = current.childTransformation + } + return chain.asReversed().map { convertTransformation(it) } + } + + /** + * Convert a single old [transformation][QuerySpTransformation] into its generalized counterpart. + */ + private fun convertTransformation(transformation: QuerySpTransformation): SpTransformation = + when (transformation) { + is QuerySpBuffer -> SpBuffer( + distance = transformation.distance, + geography = transformation.geography, + quadSegments = transformation.quadSegments, + joinStyle = transformation.joinStyle?.value?.let { JsEnum.getDefined(it, SpJoinStyle::class) }, + joinLimit = transformation.joinLimit, + endCap = transformation.endCap?.value?.let { JsEnum.getDefined(it, SpEndCap::class) }, + side = transformation.side?.value?.let { JsEnum.getDefined(it, SpSide::class) } + ) + else -> throw NakshaException( + UNSUPPORTED_OPERATION, + "Can't convert spatial transformation of type '${transformation::class.simpleName}'" + ) + } + + // ------------------------------------------------------< ref-tiles >------------------------------------------------------ + + /** + * Convert the deprecated [refTiles][RequestQuery.refTiles] list into a single [operation][Op] that + * matches features whose reference point lies in any of the given tiles; `null`, if the list is empty. + */ + private fun convertRefTiles(refTiles: IntList): Op? { + val ranges = refTiles.filterNotNull().map { tileRange(HereTile(it)) } + return when (ranges.size) { + 0 -> null + 1 -> ranges[0] + else -> Or(*ranges.toTypedArray()) + } + } + + /** + * Build the here-tile range operation `here_tile BETWEEN lower AND upper` for the given [tile] + */ + private fun tileRange(tile: HereTile): Op = And( + Gte(XyzMembers.XyzHereTile, tile.maxLevelLowerBound().intKey), + Lte(XyzMembers.XyzHereTile, tile.maxLevelUpperBound().intKey) + ) + + // -------------------------------------------------------< members >------------------------------------------------------- + + /** + * Convert a single _(possibly nested)_ [member-query][IMemberQuery] into an [operation][Op]. + * + * The queried column is taken from the [member][MemberQuery.member] of the query, so any member can + * be queried. + */ + private fun convertMember(query: IMemberQuery): Op = when (query) { + is MemberAnd -> And(*query.filterNotNull().map { convertMember(it) }.toTypedArray()) + is MemberOr -> Or(*query.filterNotNull().map { convertMember(it) }.toTypedArray()) + is MemberNot -> Not(convertMember(query.query)) + is MemberQuery -> convertMemberQuery(query) + else -> throw NakshaException( + UNSUPPORTED_OPERATION, "Can't convert member-query of type '${query::class.simpleName}'" + ) + } + + /** + * Convert a single [MemberQuery] into the matching operation, depending on its + * [operation][MemberQuery.op]. + */ + private fun convertMemberQuery(query: MemberQuery): Op { + val member: Member = query.member + val value: Any? = query.value + return when (val op = query.op) { + is StringOp -> when (op) { + StringOp.EQUALS -> Equals(member, value) + StringOp.NOT_EQUALS -> Not(Equals(member, value)) + StringOp.STARTS_WITH -> StartsWith( + member, value as? String ?: throw illegalArg("STARTS_WITH requires a string value") + ) + else -> throw NakshaException(UNSUPPORTED_OPERATION, "Unsupported string operation '$op'") + } + is DoubleOp -> when (op) { + DoubleOp.EQ -> Equals(member, value) + DoubleOp.NE -> Not(Equals(member, value)) + DoubleOp.GT -> Gt(member, requireValue(value)) + DoubleOp.GTE -> Gte(member, requireValue(value)) + DoubleOp.LT -> Lt(member, requireValue(value)) + DoubleOp.LTE -> Lte(member, requireValue(value)) + else -> throw NakshaException(UNSUPPORTED_OPERATION, "Unsupported double operation '$op'") + } + AnyOp.IS_NULL -> IsNull(member) + AnyOp.IS_NOT_NULL -> Not(IsNull(member)) + AnyOp.IS_TRUE -> IsTrue(member) + AnyOp.IS_FALSE -> IsFalse(member) + AnyOp.EXISTS -> Not(IsNull(member)) + AnyOp.IS_ANY_OF -> buildIsAnyOf(member, value) + else -> throw NakshaException( + UNSUPPORTED_OPERATION, "Member operation '$op' has no generalized equivalent" + ) + } + } + + /** + * Build an [IsAnyOf] operation from a member and an array/list-shaped [value]. + */ + private fun buildIsAnyOf(member: Member, value: Any?): Op { + val items: List = when (value) { + null -> emptyList() + is List<*> -> value.filterNotNull() + is Array<*> -> value.filterNotNull() + else -> listOf(value) + } + return IsAnyOf(member, *items.toTypedArray()) + } + + private fun requireValue(value: Any?): Any = + value ?: throw illegalArg("The operation requires a non-null value") + } +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpBuffer.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpBuffer.kt new file mode 100644 index 0000000000..1467781d62 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpBuffer.kt @@ -0,0 +1,55 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableEnum +import naksha.base.NullableProperty +import kotlin.js.JsExport +import kotlin.js.JsName +import kotlin.jvm.JvmOverloads + +/** + * Computes a POLYGON or MULTIPOLYGON that represents all points whose distance from a geometry/geography + * is less than or equal to a given distance. + */ +@JsExport +open class SpBuffer() : SpTransformation() { + @JsName("of") + @JvmOverloads + constructor( + distance: Double, + geography: Boolean = true, + quadSegments: Int? = null, + joinStyle: SpJoinStyle? = null, + joinLimit: Double? = null, + endCap: SpEndCap? = null, + side: SpSide? = null + ) : this() { + this.distance = distance + this.geography = geography + this.quadSegments = quadSegments + this.joinStyle = joinStyle + this.joinLimit = joinLimit + this.endCap = endCap + this.side = side + } + + companion object SpBuffer_C { + private val DOUBLE = NotNullProperty(Double::class) { _,_ -> 0.0 } + private val DOUBLE_NULL = NullableProperty(Double::class) + private val BOOLEAN = NotNullProperty(Boolean::class) { _,_ -> true } + private val INT_NULL = NullableProperty(Int::class) + private val JOIN_STYLE = NullableEnum(SpJoinStyle::class) + private val ENDCAP_NULL = NullableEnum(SpEndCap::class) + private val SIDE_NULL = NullableEnum(SpSide::class) + } + + var distance by DOUBLE + var geography by BOOLEAN + var quadSegments by INT_NULL + var joinStyle by JOIN_STYLE + var joinLimit by DOUBLE_NULL + var endCap by ENDCAP_NULL + var side by SIDE_NULL +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpEndCap.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpEndCap.kt new file mode 100644 index 0000000000..49e3f31e3d --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpEndCap.kt @@ -0,0 +1,20 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.JsEnum +import kotlin.js.JsExport +import kotlin.reflect.KClass + +@JsExport +class SpEndCap : JsEnum() { + companion object SpEndCap_C { + val ROUND = def(SpEndCap::class, "round") + val BUTT = def(SpEndCap::class, "butt").alias("flat") + } + + @Suppress("NON_EXPORTABLE_TYPE") + override fun namespace(): KClass = SpEndCap::class + + override fun initClass() {} +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpJoinStyle.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpJoinStyle.kt new file mode 100644 index 0000000000..7a6147728c --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpJoinStyle.kt @@ -0,0 +1,24 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.JsEnum +import kotlin.js.JsExport +import kotlin.reflect.KClass + +/** + * The join style. + */ +@JsExport +class SpJoinStyle : JsEnum() { + companion object SpJoinStyle_C { + val ROUND = def(SpJoinStyle::class, "round") + val MITRE = def(SpJoinStyle::class, "mitre") + val BEVEL = def(SpJoinStyle::class, "bevel") + } + + @Suppress("NON_EXPORTABLE_TYPE") + override fun namespace(): KClass = SpJoinStyle::class + + override fun initClass() {} +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpSide.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpSide.kt new file mode 100644 index 0000000000..42f4cc167c --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpSide.kt @@ -0,0 +1,21 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.JsEnum +import kotlin.js.JsExport +import kotlin.reflect.KClass + +@JsExport +class SpSide : JsEnum() { + companion object SpSide_C { + val BOTH = def(SpSide::class, "both") + val LEFT = def(SpSide::class, "left") + val RIGHT = def(SpSide::class, "right") + } + + @Suppress("NON_EXPORTABLE_TYPE") + override fun namespace(): KClass = SpSide::class + + override fun initClass() {} +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpTransformation.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpTransformation.kt new file mode 100644 index 0000000000..c34e38a853 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpTransformation.kt @@ -0,0 +1,14 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.AnyObject +import naksha.base.NullableProperty +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Base class for all transformations to be applied to client geometries. + */ +@JsExport +open class SpTransformation() : AnyObject() \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpTransformationList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpTransformationList.kt new file mode 100644 index 0000000000..dd7997d6c6 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/SpTransformationList.kt @@ -0,0 +1,10 @@ +@file:OptIn(ExperimentalJsExport::class) + +package naksha.model.request.ops + +import naksha.base.ListProxy +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +@JsExport +class SpTransformationList : ListProxy(SpTransformation::class) diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/StartsWith.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/StartsWith.kt new file mode 100644 index 0000000000..5244d58e8a --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/StartsWith.kt @@ -0,0 +1,31 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the member at [at] starts with the given [value]. + * @since 3.0 + */ +@JsExport +class StartsWith() : Op() { + companion object StartsWith_C { + private val VALUE = NotNullProperty(String::class) { _,_ -> "" } + } + + @JsName("forName") + constructor(at: String, value: String) : this() { + this.op = STARTS_WITH + this.at = at + this.value = value + } + + @JsName("forMember") + constructor(at: Member, value: String) : this(at.name, value) + + var value: String by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagEquals.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagEquals.kt new file mode 100644 index 0000000000..46134e0db6 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagEquals.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] equals the given [value]. + * @since 3.0 + */ +@JsExport +class TagEquals() : Op() { + companion object TagEquals_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val VALUE = NullableProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, key: String, value: Any?) : this() { + this.op = TAG_EQ + this.at = at + this.key = key + this.value = value + } + + @JsName("forMember") + constructor(at: Member, key: String, value: Any?) : this(at.name, key, value) + + var key: String by KEY + var value: Any? by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagGt.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagGt.kt new file mode 100644 index 0000000000..d16f232ce1 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagGt.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] is greater than the given [value]. + * @since 3.0 + */ +@JsExport +class TagGt() : Op() { + companion object TagGt_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, key: String, value: Any) : this() { + this.op = TAG_GT + this.at = at + this.key = key + this.value = value + } + + @JsName("forMember") + constructor(at: Member, key: String, value: Any) : this(at.name, key, value) + + var key: String by KEY + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagGte.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagGte.kt new file mode 100644 index 0000000000..d65c9fb74c --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagGte.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] is greater than or equal to the given [value]. + * @since 3.0 + */ +@JsExport +class TagGte() : Op() { + companion object TagGte_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, key: String, value: Any) : this() { + this.op = TAG_GTE + this.at = at + this.key = key + this.value = value + } + + @JsName("forMember") + constructor(at: Member, key: String, value: Any) : this(at.name, key, value) + + var key: String by KEY + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagIsNull.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagIsNull.kt new file mode 100644 index 0000000000..f25b660c7f --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagIsNull.kt @@ -0,0 +1,31 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] is null. + * @since 3.0 + */ +@JsExport +class TagIsNull() : Op() { + companion object TagIsNull_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + } + + @JsName("forName") + constructor(at: String, key: String) : this() { + this.op = TAG_IS_NULL + this.at = at + this.key = key + } + + @JsName("forMember") + constructor(at: Member, key: String) : this(at.name, key) + + var key: String by KEY +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContains.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContains.kt new file mode 100644 index 0000000000..1a89488ee0 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContains.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the given [item] exist in the member at [at], expects the member to be a tag-list. + * @since 3.0 + */ +@JsExport +class TagListContains() : Op() { + companion object TagListHasAllOf_C { + private val ITEM = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, item: Any) : this() { + this.op = TAGLIST_CONTAINS + this.at = at + this.item = item + } + + @JsName("forMember") + constructor(at: Member, item: Any) : this() { + this.op = TAGLIST_CONTAINS + this.at = at.name + this.item = item + } + + var item: Any by ITEM +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContainsAllOf.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContainsAllOf.kt new file mode 100644 index 0000000000..6443f2a296 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContainsAllOf.kt @@ -0,0 +1,33 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.AnyList +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if all of the given [items] exist in the member at [at]. + * @since 3.0 + */ +@JsExport +class TagListContainsAllOf() : Op() { + companion object TagListHasAllOf_C { + private val ITEMS = NotNullProperty(AnyList::class) { _, _ -> AnyList() } + } + + @JsName("forName") + constructor(at: String, vararg items: Any) : this() { + this.op = TAGLIST_CONTAINS_ALL_OF + this.at = at + val _items = this.items + for (item in items) _items.add(item) + } + + @JsName("forMember") + constructor(at: Member, vararg items: Any) : this(at.name, *items) + + var items: AnyList by ITEMS +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContainsAnyOf.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContainsAnyOf.kt new file mode 100644 index 0000000000..2664c1d7b4 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagListContainsAnyOf.kt @@ -0,0 +1,33 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.AnyList +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if any of the given [items] exist in the member at [at]. + * @since 3.0 + */ +@JsExport +class TagListContainsAnyOf() : Op() { + companion object TagListHasAnyOf_C { + private val ITEMS = NotNullProperty(AnyList::class) { _, _ -> AnyList() } + } + + @JsName("forName") + constructor(at: String, vararg items: Any) : this() { + this.op = TAGLIST_CONTAINS_ANY_OF + this.at = at + val _items = this.items + for (item in items) _items.add(item) + } + + @JsName("forMember") + constructor(at: Member, vararg items: Any) : this(at.name, *items) + + var items: AnyList by ITEMS +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagLt.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagLt.kt new file mode 100644 index 0000000000..8907724336 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagLt.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] is less than the given [value]. + * @since 3.0 + */ +@JsExport +class TagLt() : Op() { + companion object TagLt_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, key: String, value: Any) : this() { + this.op = TAG_LT + this.at = at + this.key = key + this.value = value + } + + @JsName("forMember") + constructor(at: Member, key: String, value: Any) : this(at.name, key, value) + + var key: String by KEY + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagLte.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagLte.kt new file mode 100644 index 0000000000..ad4ece3e8f --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagLte.kt @@ -0,0 +1,35 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] is less than or equal to the given [value]. + * @since 3.0 + */ +@JsExport +class TagLte() : Op() { + companion object TagLte_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val VALUE = NotNullProperty(Any::class) + } + + @JsName("forName") + constructor(at: String, key: String, value: Any) : this() { + this.op = TAG_LTE + this.at = at + this.key = key + this.value = value + } + + @JsName("forMember") + constructor(at: Member, key: String, value: Any) : this(at.name, key, value) + + var key: String by KEY + var value: Any by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasAllOf.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasAllOf.kt new file mode 100644 index 0000000000..f52405d289 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasAllOf.kt @@ -0,0 +1,33 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.StringList +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if all of the given [keys] exist on the member at [at]. + * @since 3.0 + */ +@JsExport +class TagMapHasAllOf() : Op() { + companion object TagHasAllOf_C { + private val KEYS = NotNullProperty(StringList::class) { _, _ -> StringList() } + } + + @JsName("forName") + constructor(at: String, vararg keys: String) : this() { + this.op = TAGMAP_HAS_ALL_OF + this.at = at + val _tagKeys = this.tagKeys + for (key in keys) _tagKeys.add(key) + } + + @JsName("forMember") + constructor(at: Member, vararg keys: String) : this(at.name, *keys) + + var tagKeys: StringList by KEYS +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasAnyOf.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasAnyOf.kt new file mode 100644 index 0000000000..4444dc5be4 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasAnyOf.kt @@ -0,0 +1,33 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.base.StringList +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if any of the given [keys] exist on the member at [at]. + * @since 3.0 + */ +@JsExport +class TagMapHasAnyOf() : Op() { + companion object TagHasAnyOf_C { + private val KEYS = NotNullProperty(StringList::class) { _, _ -> StringList() } + } + + @JsName("forName") + constructor(at: String, vararg keys: String) : this() { + this.op = TAGMAP_HAS_ANY_OF + this.at = at + val _tagKeys = this.tagKeys + for (key in keys) _tagKeys.add(key) + } + + @JsName("forMember") + constructor(at: Member, vararg keys: String) : this(at.name, *keys) + + var tagKeys: StringList by KEYS +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasKey.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasKey.kt new file mode 100644 index 0000000000..b6febccf16 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMapHasKey.kt @@ -0,0 +1,31 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] exists on the member at [at]. + * @since 3.0 + */ +@JsExport +class TagMapHasKey() : Op() { + companion object TagExists_C { + private val KEY = NotNullProperty(String::class) { _, _ -> "" } + } + + @JsName("forName") + constructor(at: String, key: String) : this() { + this.op = TAGMAP_HAS_KEY + this.at = at + this.key = key + } + + @JsName("forMember") + constructor(at: Member, key: String) : this(at.name, key) + + var key: String by KEY +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMatches.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMatches.kt new file mode 100644 index 0000000000..a2afbb4752 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagMatches.kt @@ -0,0 +1,34 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the value of the tag [key] on the member at [at] matches the given regular expression [regex]. + * @since 3.0 + */ +@JsExport +class TagMatches() : Op() { + companion object TagMatches_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val REGEX = NotNullProperty(String::class) { _,_ -> ".*" } + } + + @JsName("forName") + constructor(at: String, key: String, regex: String) : this() { + this.op = TAG_MATCHES + this.at = at + this.key = key + this.regex = regex + } + + @JsName("forMember") + constructor(at: Member, key: String, regex: String) : this(at.name, key, regex) + + var key: String by KEY + var regex: String by REGEX +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagStartsWith.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagStartsWith.kt new file mode 100644 index 0000000000..8a61471fb1 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/ops/TagStartsWith.kt @@ -0,0 +1,34 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.ops + +import naksha.base.NotNullProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Tests if the tag [key] on the member at [at] starts with the given [value]. + * @since 3.0 + */ +@JsExport +class TagStartsWith() : Op() { + companion object TagStartsWith_C { + private val KEY = NotNullProperty(String::class) { _,_ -> "" } + private val VALUE = NotNullProperty(String::class) { _,_ -> "" } + } + + @JsName("forName") + constructor(at: String, key: String, value: String) : this() { + this.op = TAG_STARTS_WITH + this.at = at + this.key = key + this.value = value + } + + @JsName("forMember") + constructor(at: Member, key: String, value: String) : this(at.name, key, value) + + var key: String by KEY + var value: String by VALUE +} diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/AnyOp.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/AnyOp.kt index 87892d6c12..e205cd68ab 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/AnyOp.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/AnyOp.kt @@ -26,8 +26,16 @@ open class AnyOp : JsEnum() { } companion object QOpCompanion { + // TODO: Update so that it works for members. + // Actually, we need operations that fit to the dedicated member types. + // CONTAINS_KEY @ Tags + // CONTAINS_ENTRY @ Tags + // CONTAINS @ Set + // IS_ANY_OF @ Primitives, like Int, Long, Double, String + // ... + /** - * Tests if the property exists. + * Tests if the field exists. * @since 3.0.0 */ @JsStatic diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IMemberQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IMemberQuery.kt new file mode 100644 index 0000000000..c10c7315f4 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IMemberQuery.kt @@ -0,0 +1,15 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.query + +import kotlin.js.JsExport + +/** + * Marker interface for member queries. + * @see MemberAnd + * @see MemberOr + * @see MemberNot + * @see MemberQuery + */ +@JsExport +interface IMemberQuery : IQuery diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IMetaQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IMetaQuery.kt deleted file mode 100644 index 38f8e4df24..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IMetaQuery.kt +++ /dev/null @@ -1,15 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model.request.query - -import kotlin.js.JsExport - -/** - * Marker interface for metadata queries. - * @see MetaAnd - * @see MetaOr - * @see MetaNot - * @see MetaQuery - */ -@JsExport -interface IMetaQuery : IQuery \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IQuery.kt index bc41e5e642..1115860f43 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IQuery.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/IQuery.kt @@ -6,7 +6,7 @@ import kotlin.js.JsExport /** * Marker interface for queries. - * @see IMetaQuery + * @see IMemberQuery * @see IPropertyQuery * @see ISpatialQuery * @see ITagQuery diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaAnd.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberAnd.kt similarity index 72% rename from here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaAnd.kt rename to here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberAnd.kt index c8b5d24924..bbd76bb464 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaAnd.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberAnd.kt @@ -10,14 +10,14 @@ import kotlin.js.JsName * Logically AND combine. */ @JsExport -class MetaAnd() : ListProxy(IMetaQuery::class), IMetaQuery { +class MemberAnd() : ListProxy(IMemberQuery::class), IMemberQuery { /** * Create a logical AND combination of the given queries. * @param queries the queries to combine. */ @JsName("of") - constructor(vararg queries: IMetaQuery) : this() { + constructor(vararg queries: IMemberQuery) : this() { addAll(queries) } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaNot.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberNot.kt similarity index 72% rename from here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaNot.kt rename to here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberNot.kt index d4dd25fa55..58928b140c 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaNot.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberNot.kt @@ -11,19 +11,19 @@ import kotlin.js.JsName * Negates the query. */ @JsExport -class MetaNot() : AnyObject(), IMetaQuery { +class MemberNot() : AnyObject(), IMemberQuery { /** * Create a negation of the given query. * @param query the query to negate. */ @JsName("of") - constructor(query: IMetaQuery) : this() { + constructor(query: IMemberQuery) : this() { this.query = query } companion object SpNot_C { - private val QUERY = NotNullProperty(IMetaQuery::class) + private val QUERY = NotNullProperty(IMemberQuery::class) } /** diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaOr.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberOr.kt similarity index 72% rename from here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaOr.kt rename to here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberOr.kt index cb9a175b36..0e0f0c220a 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaOr.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberOr.kt @@ -10,14 +10,14 @@ import kotlin.js.JsName * Logically OR combine. */ @JsExport -class MetaOr() : ListProxy(IMetaQuery::class), IMetaQuery { +class MemberOr() : ListProxy(IMemberQuery::class), IMemberQuery { /** * Create a logical AND combination of the given queries. * @param queries the queries to combine. */ @JsName("of") - constructor(vararg queries: IMetaQuery) : this() { + constructor(vararg queries: IMemberQuery) : this() { addAll(queries) } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberQuery.kt new file mode 100644 index 0000000000..4c4c22b32f --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MemberQuery.kt @@ -0,0 +1,52 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.model.request.query + +import naksha.base.AnyObject +import naksha.base.NotNullProperty +import naksha.base.NullableProperty +import naksha.model.objects.Member +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * Query a [member][naksha.model.objects.Member]. + * @since 3.0 + */ +@JsExport +open class MemberQuery() : AnyObject(), IMemberQuery { + /** + * Create an initialized member query. + * @param member the member to query. + * @param op the operation to execute. + * @param value the parameter value of the operation. + * @since 3.0 + */ + @JsName("of") + constructor(member: Member, op: AnyOp, value: Any? = null) : this() { + this.member = member + this.op = op + this.value = value + } + + companion object MemberQuery_C { + private val MEMBERS = NotNullProperty(Member::class) + private val QUERY_OP = NotNullProperty(AnyOp::class) + private val ANY = NullableProperty(Any::class) + } + + /** + * The column to query. + */ + var member: Member by MEMBERS + + /** + * The operation to execute. + */ + var op: AnyOp by QUERY_OP + + /** + * The parameter value of the operation; if any. + */ + var value: Any? by ANY +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaColumn.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaColumn.kt deleted file mode 100644 index ecea02e70f..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaColumn.kt +++ /dev/null @@ -1,669 +0,0 @@ -@file:Suppress("OPT_IN_USAGE", "MemberVisibilityCanBePrivate") - -package naksha.model.request.query - -import naksha.base.AnyObject -import naksha.base.NotNullProperty -import kotlin.js.JsExport -import kotlin.js.JsName -import kotlin.js.JsStatic -import kotlin.jvm.JvmStatic - -/** - * The meta-columns are virtual columns that can be searched by. - * - * Beware, that not all storages may support all columns as search target, therefore it is not recommended to search directly against these columns. They represent the properties of a [Tuple][naksha.model.Tuple] and its [Metadata][naksha.model.Metadata], and can be used when searching for features via [ReadFeatures][naksha.model.request.ReadFeatures]. Within a [ReadFeatures][naksha.model.request.ReadFeatures] search, the property [request.query][naksha.model.request.ReadFeatures.query] refers to a [RequestQuery][naksha.model.request.RequestQuery], which allows searching for meta-columns via [request.query.metadata][naksha.model.request.RequestQuery.metadata], which should be set to a [MetaQuery][naksha.model.request.query.MetaQuery]. - * - * For example: - * ```kotlin - * val request = ReadFeatures() - * request.query.metadata = - * MetaQuery(MetaColumn.author(), StringOp.EQUALS, "foo") - * ``` - * To query for `author` and `appId`, an [MetaAnd] can be used: - * ```kotlin - * val request = ReadFeatures() - * request.query.metadata = MetaAnd( - * MetaQuery(MetaColumn.author(), StringOp.EQUALS, "foo"), - * MetaQuery(MetaColumn.appId(), StringOp.EQUALS, "bar") - * ) - * ``` - * - * @since 3.0 - * @see [MetaQuery] - * @see [MetaAnd] - * @see [MetaOr] - * @see [MetaNot] - */ -@JsExport -open class MetaColumn() : AnyObject() { - - /** - * Create a column reference. - * @param name the field name. - * @since 3.0.0 - */ - @JsName("of") - constructor(name: String) : this() { - this.name = name - } - - override fun toString(): String = getOr("name", "") - override fun hashCode(): Int = toString().hashCode() - override fun equals(other: Any?): Boolean = toString() == other.toString() - - companion object TupleColumn_C { - /** - * The name of the virtual columns that stores the [feature-id][naksha.model.Tuple.id]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_ANY_OF] - */ - const val ID = "id" - - /** - * Returns a new meta-column for [ID]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun id(): MetaColumn = MetaColumn(ID) - - /** - * The name of the virtual columns that stores the [creation timestamp][naksha.model.Metadata.createdAt]. - * - * This value is exposed through [naksha.model.XyzNs.createdAt]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val CREATED_AT = "createdAt" - - /** - * Returns a new meta-column for [CREATED_AT]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun createdAt(): MetaColumn = MetaColumn(CREATED_AT) - - /** - * The name of the virtual columns that stores the feature-number. - * - * This value is exposed through [naksha.model.XyzNs.uuid]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val FN = "fn" - - /** - * Returns a new meta-column for [FN]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun fn(): MetaColumn = MetaColumn(FN) - - /** - * The name of the virtual columns that stores the [update timestamp][naksha.model.Metadata.updatedAt]. - * - * This value is exposed through [naksha.model.XyzNs.updatedAt]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val UPDATED_AT = "updatedAt" - - /** - * Returns a new meta-column for [UPDATED_AT]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun updatedAt(): MetaColumn = MetaColumn(UPDATED_AT) - - /** - * The amount of changes that have been applied to a feature, a value between `1` and `2,147,483,647`. - * - * This value is exposed through [naksha.model.XyzNs.changeCount]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val CHANGE_COUNT = "changeCount" - - /** - * Returns a new meta-column for [CHANGE_COUNT]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun changeCount(): MetaColumn = MetaColumn(CHANGE_COUNT) - - /** - * The name of the virtual column that exposes the binary [action][naksha.model.Action], stored in the lower two bits of [naksha.model.Metadata.tupleNumber]'s version. - * - * This value is exposed through [naksha.model.XyzNs.action]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val ACTION = "action" - - /** - * Returns a new meta-column for [ACTION]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun action(): MetaColumn = MetaColumn(ACTION) - - /** - * The name of the virtual columns that stores the [hash][naksha.model.Metadata.calculateHash]. - * - * This value is exposed through [naksha.model.XyzNs.hash]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val HASH = "hash" - - /** - * Returns a new meta-column for [HASH]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun hash(): MetaColumn = MetaColumn(HASH) - - /** - * The name of the virtual column that stores the [next version][naksha.model.Metadata.nextVersion]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val NEXT_VERSION = "next_version" - - /** - * Returns a new meta-column for [NEXT_VERSION]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun nextVersion(): MetaColumn = MetaColumn(NEXT_VERSION) - - /** - * The name of the virtual columns that stores the [version][naksha.model.Metadata.version] (_transaction number_). - * - * This value is exposed through [naksha.model.XyzNs.version]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val VERSION = "txn" - - /** - * Returns a new meta-column for [VERSION]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun version(): MetaColumn = MetaColumn(VERSION) - - /** - * The name of the virtual columns that stores the [uid][naksha.model.Metadata.uid]. - * - * This value is exposed through [naksha.model.XyzNs.uid]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val UID = "uid" - - /** - * Returns a new meta-column for [UID]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun uid(): MetaColumn = MetaColumn(UID) - - /** - * The name of the virtual column that stores the [tuple-number][naksha.model.Metadata.tupleNumber], encoded as [160-bit variant][naksha.model.TupleNumberVariant.B160], as generated by [TupleNumber.toByteArray][naksha.model.TupleNumber.toByteArray]. - * - * This value is part of the [naksha.model.XyzNs.uuid]. - * - * Supported [query operations][AnyOp] are: - * - [AnyOp.IS_ANY_OF] - * @see [naksha.model.TupleNumberVariant.B160] - */ - const val TUPLE_NUMBER = "tuple_number" - - /** - * Returns a new meta-column for [TUPLE_NUMBER]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun tupleNumber(): MetaColumn = MetaColumn(TUPLE_NUMBER) - - /** - * The name of the virtual column that stores the [base tuple-number][naksha.model.Metadata.baseTupleNumber], encoded as [128-bit variant][naksha.model.TupleNumberVariant.B128], as generated by [TupleNumber.toByteArray][naksha.model.TupleNumber.toByteArray]. - * - * This value is part of the [naksha.model.XyzNs.muuid]. - * - * Supported [query operations][AnyOp] are: - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - * @see [naksha.model.TupleNumberVariant.B128] - */ - const val BASE_TUPLE_NUMBER = "base_tn" - - /** - * Returns a new meta-column for [BASE_TUPLE_NUMBER]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun baseTupleNumber(): MetaColumn = MetaColumn(BASE_TUPLE_NUMBER) - - /** - * The name of the virtual column that stores the [binary HERE tile number][naksha.geo.HereTile], indexing the [metadata HERE tile number][naksha.model.Metadata.hereTile]. - * - * The [binary HERE tile number][naksha.geo.HereTile] where the [reference-point][naksha.model.objects.NakshaFeature.referencePoint] of the [feature][naksha.model.objects.NakshaFeature] is located. It is possible to search directly the grid, but another options is to use the specialise [SpRefInHereTile] query. While this is more flexible, the specialised query will have a much better cache rate, and may run much faster. - * - * This value is part of the [naksha.model.XyzNs.hereTile]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val HERE_TILE = "hereTile" - - /** - * Returns a new meta-column for [HERE_TILE]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun hereTile(): MetaColumn = MetaColumn(HERE_TILE) - - /** - * The name of the virtual column that stores the [author][naksha.model.Metadata.author]. - * - * This value is exposed as [naksha.model.XyzNs.author]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val AUTHOR = "author" - - /** - * Returns a new meta-column for [AUTHOR]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun author(): MetaColumn = MetaColumn(AUTHOR) - - /** - * The name of the virtual column that stores the [origin][naksha.model.Metadata.origin] as string. - * - * This value is exposed as [naksha.model.XyzNs.origin]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val ORIGIN = "origin" - - /** - * Returns a new meta-column for [ORIGIN]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun origin(): MetaColumn = MetaColumn(ORIGIN) - - /** - * The name of the virtual column that stores the [target][naksha.model.Metadata.target] as string. - * - * This value is exposed as [naksha.model.XyzNs.target]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val TARGET = "target" - - /** - * Returns a new meta-column for [TARGET]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun target(): MetaColumn = MetaColumn(TARGET) - - /** - * The name of the virtual columns that stores the [author change timestamp][naksha.model.Metadata.authorTs]. - * - * This value is exposed as [naksha.model.XyzNs.authorTs]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val AUTHOR_TS = "author_ts" - - /** - * Returns a new meta-column for [AUTHOR_TS]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun authorTs(): MetaColumn = MetaColumn(AUTHOR_TS) - - /** - * The name of the virtual columns that stores the [author change timestamp][naksha.model.Metadata.appId]. - * - * This value is exposed as [naksha.model.XyzNs.appId]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_ANY_OF] - */ - const val APP_ID = "app_id" - - /** - * Returns a new meta-column for [APP_ID]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun appId(): MetaColumn = MetaColumn(APP_ID) - - /** - * The name of the virtual columns that stores the [type][naksha.model.Metadata.ft]. - * - * This value is exposed as [naksha.model.XyzNs.featureType]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val FEATURE_TYPE = "type" - - /** - * Returns a new meta-column for [FEATURE_TYPE]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun featureType(): MetaColumn = MetaColumn(FEATURE_TYPE) - - /** - * The name of the virtual columns that stores the [cv][naksha.model.Metadata.cv0] (_custom value_). - * - * This value is exposed through [naksha.model.XyzNs.cv0]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val CV0 = "cv0" - - /** - * Returns a new meta-column for [CV0]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cv0(): MetaColumn = MetaColumn(CV0) - - /** - * The name of the virtual columns that stores the [cv][naksha.model.Metadata.cv1] (_custom value_). - * - * This value is exposed through [naksha.model.XyzNs.cv1]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val CV1 = "cv1" - - /** - * Returns a new meta-column for [CV1]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cv1(): MetaColumn = MetaColumn(CV1) - - /** - * The name of the virtual columns that stores the [cv][naksha.model.Metadata.cv2] (_custom value_). - * - * This value is exposed through [naksha.model.XyzNs.cv2]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val CV2 = "cv2" - - /** - * Returns a new meta-column for [CV2]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cv2(): MetaColumn = MetaColumn(CV2) - - /** - * The name of the virtual columns that stores the [cv][naksha.model.Metadata.cv3] (_custom value_). - * - * This value is exposed through [naksha.model.XyzNs.cv3]. - * - * Supported [query operations][AnyOp] are: - * - [DoubleOp] - * - [AnyOp.IS_ANY_OF] - */ - const val CV3 = "cv3" - - /** - * Returns a new meta-column for [CV3]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cv3(): MetaColumn = MetaColumn(CV3) - - /** - * The name of the virtual columns that stores the [first custom value][naksha.model.Metadata.cs0]. - * - * This value is exposed as [naksha.model.XyzNs.cs0]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val CS0 = "cs0" - - /** - * Returns a new meta-column for [CS0]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cs0(): MetaColumn = MetaColumn(CS0) - - /** - * The name of the virtual columns that stores the [first custom value][naksha.model.Metadata.cs1]. - * - * This value is exposed as [naksha.model.XyzNs.cs1]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val CS1 = "cs1" - - /** - * Returns a new meta-column for [CS1]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cs1(): MetaColumn = MetaColumn(CS1) - - /** - * The name of the virtual columns that stores the [first custom value][naksha.model.Metadata.cs2]. - * - * This value is exposed as [naksha.model.XyzNs.cs2]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val CS2 = "cs2" - - /** - * Returns a new meta-column for [CS2]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cs2(): MetaColumn = MetaColumn(CS2) - - /** - * The name of the virtual columns that stores the [first custom value][naksha.model.Metadata.cs3]. - * - * This value is exposed as [naksha.model.XyzNs.cs3]. - * - * Supported [query operations][AnyOp] are: - * - [StringOp] - * - [AnyOp.IS_NULL] - * - [AnyOp.IS_NOT_NULL] - * - [AnyOp.IS_ANY_OF] - */ - const val CS3 = "cs3" - - /** - * Returns a new meta-column for [CS3]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun cs3(): MetaColumn = MetaColumn(CS3) - - /** - * The name of the virtual columns that stores the [feature][naksha.model.Tuple.feature]. - * - * This can only be queried using a special [property query][IPropertyQuery]. - */ - const val FEATURE = "feature" - - /** - * Returns a new meta-column for [FEATURE]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun feature(): MetaColumn = MetaColumn(FEATURE) - - /** - * The name of the virtual columns that stores the geometry. - * - * This can only be queried using a special [spatial query][ISpatialQuery]. - */ - const val GEOMETRY = "geo" - - /** - * Returns a new meta-column for [GEOMETRY]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun geometry(): MetaColumn = MetaColumn(GEOMETRY) - - /** - * The name of the virtual columns that stores the reference point. - * - * This can only be queried using a special [spatial query][ISpatialQuery]. - */ - const val REF_POINT = "referencePoint" - - /** - * Returns a new meta-column for [REF_POINT]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun referencePoint(): MetaColumn = MetaColumn(REF_POINT) - - /** - * The name of the virtual columns that stores the tags. - * - * This can only be queried using a special [tag query][ITagQuery]. - */ - const val TAGS = "tags" - - /** - * Returns a new meta-column for [TAGS]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun tags(): MetaColumn = MetaColumn(TAGS) - - /** - * The name of the virtual columns that stores the attachment. - * - * Attachments can't be queried! - */ - const val ATTACHMENT = "attachment" - - /** - * Returns a new meta-column for [ATTACHMENT]. - * @return a new meta-column. - */ - @JvmStatic - @JsStatic - fun attachment(): MetaColumn = MetaColumn(ATTACHMENT) - - private val NAME = NotNullProperty(String::class) { _, _ -> "" } - } - - /** - * The name of the field. - */ - var name by NAME -} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaQuery.kt deleted file mode 100644 index 48e4e75bca..0000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/MetaQuery.kt +++ /dev/null @@ -1,51 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model.request.query - -import naksha.base.AnyObject -import naksha.base.NotNullProperty -import naksha.base.NullableProperty -import kotlin.js.JsExport -import kotlin.js.JsName - -/** - * A meta-data query within the Naksha feature. - * @since 3.0 - */ -@JsExport -open class MetaQuery() : AnyObject(), IMetaQuery { - /** - * Create an initialized property query. - * @param column the column of the metadata to query. - * @param op the operation to execute. - * @param value the parameter value of the operation. - * @since 3.0 - */ - @JsName("of") - constructor(column: MetaColumn, op: AnyOp, value: Any? = null) : this() { - this.column = column - this.op = op - this.value = value - } - - companion object PropertyQueryCompanion { - private val COLUMNS = NotNullProperty(MetaColumn::class) - private val QUERY_OP = NotNullProperty(AnyOp::class) - private val ANY = NullableProperty(Any::class) - } - - /** - * The column to query. - */ - var column by COLUMNS - - /** - * The operation to execute. - */ - var op by QUERY_OP - - /** - * The parameter value of the operation; if any. - */ - var value by ANY -} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/Property.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/Property.kt index 806be4a1d0..d109d3e016 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/Property.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/Property.kt @@ -2,15 +2,13 @@ package naksha.model.request.query -import naksha.base.NotNullProperty import naksha.base.PlatformListApi.PlatformListApiCompanion.array_get import naksha.base.PlatformListApi.PlatformListApiCompanion.array_get_length -import naksha.base.StringList import naksha.base.fn.Fn1 +import naksha.model.objects.Member +import naksha.model.objects.StandardMembers import kotlin.js.JsExport import kotlin.js.JsName -import kotlin.js.JsStatic -import kotlin.jvm.JvmStatic /** * The reference to a property within a feature. @@ -18,11 +16,11 @@ import kotlin.jvm.JvmStatic * **Warning:** You should not search for the `id`, `geometry`, or anything from [`properties->@ns:com:here:xyz`][naksha.model.XyzNs] using this query, because there are specialized, and optimized, dedicated queries available. So avoid things like `PQuery(Property("id"), StringOp.EQUALS, "foo"`. * @see naksha.model.request.ReadFeatures.featureIds * @see ISpatialQuery - * @see IMetaQuery + * @see IMemberQuery * @see ITagQuery */ @JsExport -open class Property() : MetaColumn(FEATURE) { +open class Property() : Member(StandardMembers.Feature.name) { /** * Create a property from a path given as variable argument list. @@ -45,15 +43,8 @@ open class Property() : MetaColumn(FEATURE) { const val XYZ = "@ns:com:here:xyz" const val TAGS = "tags" - - private val PATH = NotNullProperty(StringList::class) } - /** - * The path inside the feature. - */ - val path by PATH - private var array: Array? = null private var string: String? = null diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagExists.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagExists.kt index 947076a930..35332c4410 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagExists.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagExists.kt @@ -8,8 +8,8 @@ import kotlin.js.JsName /** * Tests if the tag with given name exists, ignoring the value. * - * For map-form tags ([naksha.model.objects.MemberType.TAGS] / [naksha.model.objects.MemberType.TAGS_FROM_ARRAY]) - * this tests if the key exists. For set-form tags ([naksha.model.objects.MemberType.SET], the default) + * For map-form tags ([naksha.model.objects.MemberType.TAG_MAP] / [naksha.model.objects.MemberType.TAG_MAP_FROM_ARRAY]) + * this tests if the key exists. For tag-list-form tags ([naksha.model.objects.MemberType.TAG_LIST], the default) * this tests if the full string element exists, e.g. `TagExists("foo")` matches a feature tagged * `["foo", "bar"]`. * @since 3.0.0 diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagQuery.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagQuery.kt index 19bb601636..21030eb5a8 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagQuery.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagQuery.kt @@ -10,9 +10,9 @@ import kotlin.js.JsExport * A general form of a tag query without any operation. * * Note: the `TagValueIs*` and [TagValueMatches] queries operate on keys/values and therefore only - * match map-form tags ([naksha.model.objects.MemberType.TAGS] / - * [naksha.model.objects.MemberType.TAGS_FROM_ARRAY]). Against the default set-form tags - * ([naksha.model.objects.MemberType.SET]) the values are never split into key/value pairs; use + * match map-form tags ([naksha.model.objects.MemberType.TAG_MAP] / + * [naksha.model.objects.MemberType.TAG_MAP_FROM_ARRAY]). Against the default set-form tags + * ([naksha.model.objects.MemberType.TAG_LIST]) the values are never split into key/value pairs; use * [TagExists] or [TagSetContains] to match full elements instead. * @since 3.0.0 * @see TagExists diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagSetContains.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagSetContains.kt index 98b0427c0d..53c55d3026 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagSetContains.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/request/query/TagSetContains.kt @@ -8,7 +8,7 @@ import kotlin.js.JsExport import kotlin.js.JsName /** - * Tests if a set-form tags member ([naksha.model.objects.MemberType.SET], the default for the + * Tests if a tag-list-form tags member ([naksha.model.objects.MemberType.TAG_LIST], the default for the * standard `tags` member) contains the given element. * * The element is matched in its type: strings, booleans, and numbers are all supported. For string @@ -19,8 +19,8 @@ import kotlin.js.JsName * complete elements can be matched: a feature tagged `["foo=bar"]` is found by * `TagSetContains("foo=bar")`, not by `TagSetContains("foo")`. * - * This query does **not** match map-form tags ([naksha.model.objects.MemberType.TAGS] or - * [naksha.model.objects.MemberType.TAGS_FROM_ARRAY]); use [TagExists] and the `TagValueIs*` queries + * This query does **not** match map-form tags ([naksha.model.objects.MemberType.TAG_MAP] or + * [naksha.model.objects.MemberType.TAG_MAP_FROM_ARRAY]); use [TagExists] and the `TagValueIs*` queries * for those. * @since 3.0 */ diff --git a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/MemberTest.kt b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/MemberTest.kt index 34d9b113cc..19146d20a0 100644 --- a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/MemberTest.kt +++ b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/MemberTest.kt @@ -82,8 +82,8 @@ class MemberTest { fun indexTypesExist() { assertNotNull(IndexType.BTREE) assertNotNull(IndexType.SPATIAL) - assertNotNull(IndexType.TAGS) - assertNotNull(IndexType.SET) + assertNotNull(IndexType.TAG_MAP) + assertNotNull(IndexType.TAG_LIST) } @Test @@ -99,15 +99,15 @@ class MemberTest { assertNotNull(MemberType.STRING) assertNotNull(MemberType.BYTE_ARRAY) // Virtual / jsonb. - assertNotNull(MemberType.TAGS) - assertNotNull(MemberType.TAGS_FROM_ARRAY) - assertNotNull(MemberType.SET) + assertNotNull(MemberType.TAG_MAP) + assertNotNull(MemberType.TAG_MAP_FROM_ARRAY) + assertNotNull(MemberType.TAG_LIST) } @Test fun standardTagsMemberDefaultsToSet() { - assertEquals(MemberType.SET, naksha.model.objects.StandardMembers.Tags.dataType) - assertEquals(IndexType.SET, naksha.model.objects.StandardIndices.Tags.type) + assertEquals(MemberType.TAG_LIST, naksha.model.objects.StandardMembers.XyzTags.dataType) + assertEquals(IndexType.TAG_LIST, naksha.model.objects.XyzIndices.XyzTags.type) } @Test diff --git a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberQueryTest.kt b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberQueryTest.kt index f1a54d687f..75330f482e 100644 --- a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberQueryTest.kt +++ b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberQueryTest.kt @@ -3,7 +3,7 @@ package naksha.model import naksha.base.Int64 import naksha.model.request.query.AnyOp import naksha.model.request.query.MetaColumn -import naksha.model.request.query.MetaQuery +import naksha.model.request.query.MemberQuery import kotlin.random.Random import kotlin.test.Ignore import kotlin.test.Test @@ -21,7 +21,7 @@ class TupleNumberQueryTest { randomTupleNumber().toByteArray(TupleNumberVariant.B64), randomTupleNumber().toByteArray(TupleNumberVariant.B64) ) - val metaQuery = MetaQuery(MetaColumn.nextVersion(), AnyOp.IS_ANY_OF, serializedTupleNumbers) + val metaQuery = MemberQuery(MetaColumn.nextVersion(), AnyOp.IS_ANY_OF, serializedTupleNumbers) // Then assertIs>(metaQuery.value) @@ -29,8 +29,8 @@ class TupleNumberQueryTest { private fun randomTupleNumber() = TupleNumber( - storageNumber = Int64(random.nextInt(10)), - mapNumber = random.nextInt(10), + databaseNumber = Int64(random.nextInt(10)), + catalogNumber = random.nextInt(10), collectionNumber = random.nextInt(10), featureNumber = Int64(random.nextInt(10)), version = Version(Int64(random.nextInt(10))) diff --git a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberTest.kt b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberTest.kt index a1bbed192d..f37ace49e0 100644 --- a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberTest.kt +++ b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TupleNumberTest.kt @@ -27,25 +27,25 @@ class TupleNumberTest { @Test fun actionCreatedEncodedInTxn() { - val t = tn(Action.CREATED) - assertEquals(Action.CREATED, t.action) + val t = tn(Action.CREATE) + assertEquals(Action.CREATE, t.action) } @Test fun actionUpdatedEncodedInTxn() { - val t = tn(Action.UPDATED) - assertEquals(Action.UPDATED, t.action) + val t = tn(Action.UPDATE) + assertEquals(Action.UPDATE, t.action) } @Test fun actionDeletedEncodedInTxn() { - val t = tn(Action.DELETED) - assertEquals(Action.DELETED, t.action) + val t = tn(Action.DELETE) + assertEquals(Action.DELETE, t.action) } @Test fun binaryRoundTripB64() { - val t = tn(Action.CREATED) + val t = tn(Action.CREATE) val bytes = t.toByteArray(B64) assertEquals(8, bytes.size) val restored = TupleNumber.fromB64(bytes, storageNumber, mapNumber, collectionNumber, featureNumber) @@ -55,7 +55,7 @@ class TupleNumberTest { @Test fun binaryRoundTripB128() { - val t = tn(Action.UPDATED) + val t = tn(Action.UPDATE) val bytes = t.toByteArray(B128) assertEquals(16, bytes.size) val restored = TupleNumber.fromB128(bytes, storageNumber, mapNumber, collectionNumber) @@ -66,7 +66,7 @@ class TupleNumberTest { @Test fun binaryRoundTripB160() { - val t = tn(Action.DELETED) + val t = tn(Action.DELETE) val bytes = t.toByteArray(B160) assertEquals(20, bytes.size) val restored = TupleNumber.fromB160(bytes, storageNumber, mapNumber) @@ -78,11 +78,11 @@ class TupleNumberTest { @Test fun binaryRoundTripB192() { - val t = tn(Action.CREATED) + val t = tn(Action.CREATE) val bytes = t.toByteArray(B192) assertEquals(24, bytes.size) val restored = TupleNumber.fromB192(bytes, storageNumber) - assertEquals(t.mapNumber, restored.mapNumber) + assertEquals(t.catalogNumber, restored.catalogNumber) assertEquals(t.collectionNumber, restored.collectionNumber) assertEquals(t.featureNumber, restored.featureNumber) assertEquals(t.version, restored.version) @@ -90,12 +90,12 @@ class TupleNumberTest { @Test fun binaryRoundTripB256() { - val t = tn(Action.UPDATED) + val t = tn(Action.UPDATE) val bytes = t.toByteArray(B256) assertEquals(32, bytes.size) val restored = TupleNumber.fromB256(bytes) - assertEquals(t.storageNumber, restored.storageNumber) - assertEquals(t.mapNumber, restored.mapNumber) + assertEquals(t.databaseNumber, restored.databaseNumber) + assertEquals(t.catalogNumber, restored.catalogNumber) assertEquals(t.collectionNumber, restored.collectionNumber) assertEquals(t.featureNumber, restored.featureNumber) assertEquals(t.version, restored.version) @@ -103,7 +103,7 @@ class TupleNumberTest { @Test fun stringRoundTrip() { - val t = tn(Action.CREATED) + val t = tn(Action.CREATE) val s = t.toString() val parts = s.split(":") // 5 parts: storageNumber, mapNumber, collectionNumber, featureNumber, version diff --git a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/request/ops/QueryConverterTest.kt b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/request/ops/QueryConverterTest.kt new file mode 100644 index 0000000000..b3cae354fd --- /dev/null +++ b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/request/ops/QueryConverterTest.kt @@ -0,0 +1,225 @@ +@file:Suppress("DEPRECATION") + +package naksha.model.request.ops + +import naksha.geo.HereTile +import naksha.geo.PointCoord +import naksha.geo.SpPoint +import naksha.model.NakshaException +import naksha.model.objects.XyzMembers +import naksha.model.request.RequestQuery +import naksha.model.request.query.AnyOp +import naksha.model.request.query.DoubleOp +import naksha.model.request.query.MemberQuery +import naksha.model.request.query.SpIntersects +import naksha.model.request.query.SpRefInHereTile +import naksha.model.request.query.StringOp +import naksha.model.request.query.TagAnd +import naksha.model.request.query.TagExists +import naksha.model.request.query.TagNot +import naksha.model.request.query.TagSetContains +import naksha.model.request.query.TagValueIsBool +import naksha.model.request.query.TagValueIsDouble +import naksha.model.request.query.TagValueIsNull +import naksha.model.request.query.TagValueIsString +import naksha.model.request.query.TagValueMatches +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +class QueryConverterTest { + + private companion object { + const val TAGS = "tags" // XyzMembers.XyzTags.name + const val HERE_TILE = "here_tile" // XyzMembers.XyzHereTile.name + const val GEO = "geo" // XyzMembers.XyzGeometry.name + } + + /** + * Resolves the concrete operation subtype (operations read back from a list/child are typed as the + * [Op] base) and asserts it is of the expected type. + */ + private inline fun Op.expect(): T { + val real = Op.detect(this) ?: error("operation '${op}' is not detectable") + assertIs(real) + return real + } + + private fun convert(query: RequestQuery): Op? = QueryConverter.convert(query) + + private fun tagQuery(query: naksha.model.request.query.ITagQuery): Op = + convert(RequestQuery().apply { tags = query }) ?: error("expected a non-null op") + + // ------------------------------------------------------------< tags >----------------------------------------------------------- + + @Test + fun tagExistsBecomesTagMapHasKey() { + val op = tagQuery(TagExists("sample")).expect() + assertEquals(TAGS, op.at) + assertEquals("sample", op.key) + } + + @Test + fun tagValueIsNullBecomesTagIsNull() { + val op = tagQuery(TagValueIsNull("ref")).expect() + assertEquals(TAGS, op.at) + assertEquals("ref", op.key) + } + + @Test + fun tagValueIsStringBecomesTagEquals() { + val op = tagQuery(TagValueIsString("name", "john")).expect() + assertEquals(TAGS, op.at) + assertEquals("name", op.key) + assertEquals("john", op.value) + } + + @Test + fun tagValueIsBoolBecomesTagEquals() { + val op = tagQuery(TagValueIsBool("flag", true)).expect() + assertEquals("flag", op.key) + assertEquals(true, op.value) + } + + @Test + fun tagValueIsDoubleGtBecomesTagGt() { + val op = tagQuery(TagValueIsDouble("speed", DoubleOp.GT, 5.0)).expect() + assertEquals("speed", op.key) + assertEquals(5.0, op.value) + } + + @Test + fun tagValueIsDoubleNeBecomesNotTagEquals() { + val not = tagQuery(TagValueIsDouble("speed", DoubleOp.NE, 5.0)).expect() + val eq = not.child.expect() + assertEquals("speed", eq.key) + assertEquals(5.0, eq.value) + } + + @Test + fun tagValueMatchesBecomesTagMatches() { + val op = tagQuery(TagValueMatches("code", "^[a-z][0-9]+$")).expect() + assertEquals(TAGS, op.at) + assertEquals("code", op.key) + assertEquals("^[a-z][0-9]+$", op.regex) + } + + @Test + fun tagSetContainsBecomesTagListContains() { + val op = tagQuery(TagSetContains("flag:=true")).expect() + assertEquals(TAGS, op.at) + assertEquals("flag:=true", op.item) + } + + @Test + fun tagAndBecomesAndWithConvertedChildren() { + val and = tagQuery(TagAnd(TagExists("a"), TagExists("b"))).expect() + assertEquals(2, and.children.size) + assertEquals("a", and.children[0]!!.expect().key) + assertEquals("b", and.children[1]!!.expect().key) + } + + @Test + fun tagNotBecomesNot() { + val not = tagQuery(TagNot(TagExists("a"))).expect() + assertEquals("a", not.child.expect().key) + } + + // -----------------------------------------------------------< members >--------------------------------------------------------- + + @Test + fun memberEqualsBecomesEquals() { + val q = RequestQuery().apply { members = MemberQuery(XyzMembers.XyzAppId, StringOp.EQUALS, "app-1") } + val op = convert(q)!!.expect() + assertEquals(XyzMembers.XyzAppId.name, op.at) + assertEquals("app-1", op.value) + } + + @Test + fun memberStartsWithBecomesStartsWith() { + val q = RequestQuery().apply { members = MemberQuery(XyzMembers.XyzAuthor, StringOp.STARTS_WITH, "Jo") } + val op = convert(q)!!.expect() + assertEquals(XyzMembers.XyzAuthor.name, op.at) + assertEquals("Jo", op.value) + } + + @Test + fun memberIsAnyOfBecomesIsAnyOf() { + val q = RequestQuery().apply { members = MemberQuery(XyzMembers.XyzAppId, AnyOp.IS_ANY_OF, arrayOf("a", "b")) } + val op = convert(q)!!.expect() + assertEquals(2, op.items.size) + assertEquals("a", op.items[0]) + assertEquals("b", op.items[1]) + } + + @Test + fun unsupportedMemberOpThrows() { + val q = RequestQuery().apply { members = MemberQuery(XyzMembers.XyzAppId, AnyOp.CONTAINS, "x") } + assertFailsWith { convert(q) } + } + + // -----------------------------------------------------------< spatial >--------------------------------------------------------- + + @Test + fun spatialIntersectsBecomesIntersects() { + val q = RequestQuery().apply { spatial = SpIntersects(SpPoint(PointCoord(1.0, 2.0))) } + val op = convert(q)!!.expect() + assertEquals(GEO, op.at) + } + + @Test + fun spatialRefInHereTileBecomesHereTileRange() { + val tile = HereTile("122010112103") + val q = RequestQuery().apply { spatial = SpRefInHereTile(tile) } + val and = convert(q)!!.expect() + assertEquals(2, and.children.size) + val gte = and.children[0]!!.expect() + val lte = and.children[1]!!.expect() + assertEquals(HERE_TILE, gte.at) + assertEquals(HERE_TILE, lte.at) + assertEquals(tile.maxLevelLowerBound().intKey, gte.value) + assertEquals(tile.maxLevelUpperBound().intKey, lte.value) + } + + // ----------------------------------------------------------< ref-tiles >-------------------------------------------------------- + + @Test + fun refTilesBecomeOrOfRanges() { + val a = HereTile("122010112103") + val b = HereTile("122010322102") + val q = RequestQuery().apply { refTiles += listOf(a.intKey, b.intKey) } + val or = convert(q)!!.expect() + assertEquals(2, or.children.size) + or.children[0]!!.expect() + or.children[1]!!.expect() + } + + @Test + fun singleRefTileBecomesSingleRange() { + val a = HereTile("122010112103") + val q = RequestQuery().apply { refTiles += a.intKey } + // A single tile is not wrapped in an OR. + convert(q)!!.expect() + } + + // -----------------------------------------------------------< combine >--------------------------------------------------------- + + @Test + fun multipleCategoriesAreAndCombined() { + val q = RequestQuery().apply { + tags = TagExists("a") + members = MemberQuery(XyzMembers.XyzAppId, StringOp.EQUALS, "app-1") + } + val and = convert(q)!!.expect() + assertEquals(2, and.children.size) + and.children[0]!!.expect() + and.children[1]!!.expect() + } + + @Test + fun emptyQueryConvertsToNull() { + assertNull(convert(RequestQuery())) + } +} diff --git a/here-naksha-lib-model/src/jvmMain/java/naksha/model/util/RequestHelper.java b/here-naksha-lib-model/src/jvmMain/java/naksha/model/util/RequestHelper.java index 1c0de913cc..c7367fcbb5 100644 --- a/here-naksha-lib-model/src/jvmMain/java/naksha/model/util/RequestHelper.java +++ b/here-naksha-lib-model/src/jvmMain/java/naksha/model/util/RequestHelper.java @@ -44,8 +44,8 @@ private RequestHelper() { final @Nullable String collectionName, final @NotNull String featureId ) { - final ReadFeatures readFeatures = new ReadFeatures().addCollectionId(collectionName); - readFeatures.setMapId(mapId); + final ReadFeatures readFeatures = new ReadFeatures().withCollectionId(collectionName); + readFeatures.setCatalogId(mapId); readFeatures.getFeatureIds().add(featureId); return readFeatures; } @@ -62,8 +62,8 @@ private RequestHelper() { final @Nullable String collectionName, final @NotNull List featureIds ) { - final ReadFeatures readFeatures = new ReadFeatures().addCollectionId(collectionName); - readFeatures.setMapId(mapId); + final ReadFeatures readFeatures = new ReadFeatures().withCollectionId(collectionName); + readFeatures.setCatalogId(mapId); readFeatures.getFeatureIds().addAll(featureIds); return readFeatures; } diff --git a/here-naksha-lib-model/src/jvmMain/kotlin/naksha/model/TupleHeapCache.jvm.kt b/here-naksha-lib-model/src/jvmMain/kotlin/naksha/model/TupleHeapCache.jvm.kt index d0f3147fc4..b8c63310ed 100644 --- a/here-naksha-lib-model/src/jvmMain/kotlin/naksha/model/TupleHeapCache.jvm.kt +++ b/here-naksha-lib-model/src/jvmMain/kotlin/naksha/model/TupleHeapCache.jvm.kt @@ -24,7 +24,7 @@ actual class TupleHeapCache : ITupleCache { private var tuplesByStorage = AtomicMap>>() actual override fun get(tupleNumber: TupleNumber): Tuple? - = tuplesByStorage[tupleNumber.storageNumber]?.get(tupleNumber)?.deref() + = tuplesByStorage[tupleNumber.databaseNumber]?.get(tupleNumber)?.deref() actual override fun load(featureTuples: List, from: Int, to: Int, acceptFeature: Boolean): Int { var loaded = 0 @@ -47,7 +47,7 @@ actual class TupleHeapCache : ITupleCache { actual override fun put(tuple: Tuple) { val tupleNumber = tuple.tupleNumber - val storageNumber = tupleNumber.storageNumber + val storageNumber = tupleNumber.databaseNumber var storageTuples = tuplesByStorage[storageNumber] if (storageTuples == null) { storageTuples = AtomicMap() diff --git a/here-naksha-lib-model/src/jvmTest/kotlin/naksha/model/PropertyFilterTest.kt b/here-naksha-lib-model/src/jvmTest/kotlin/naksha/model/PropertyFilterTest.kt index 5461e1fe67..a2e68c8bc6 100644 --- a/here-naksha-lib-model/src/jvmTest/kotlin/naksha/model/PropertyFilterTest.kt +++ b/here-naksha-lib-model/src/jvmTest/kotlin/naksha/model/PropertyFilterTest.kt @@ -482,8 +482,8 @@ class PropertyFilterTest { collectionNumber = collectionNumber, featureNumber = featureNumber(feature.id), version = version, - members = members, - feature = featureBytes + membersBook = members, + featureBytes = featureBytes ) return FeatureTuple(tupleNumber, tuple) } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/LibPsql.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/LibPsql.kt index 4859a87b2e..63104ffb08 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/LibPsql.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/LibPsql.kt @@ -35,81 +35,66 @@ val adminVersion = NakshaVersion.of("3.0.0-beta.24") internal const val PG_S = "\$" /** - * ``: The identifier for the HEAD-table, no prefix. + * ``: The postfix for the HEAD-table, no prefix. */ internal const val PG_HEAD = "" /** - * `$del`: The identifier for the DELETION-table. - */ -internal const val PG_DEL = "${PG_S}del" - -/** - * `$hst`: The identifier for the HISTORY-table. + * `$hst`: The postfix for the HISTORY-table. */ internal const val PG_HST = "${PG_S}hst" /** - * `$meta`: The identifier for the META-table. + * `$meta`: The postfix for the META-table. */ internal const val PG_META = "${PG_S}meta" /** - * `$i_???`: The prefix used for indices, followed by the index identifier, e.g. `$i_id` - */ -internal const val PG_IDX = "${PG_S}i_" - -/** - * `$ci_???`: The prefix used for user-defined (custom) indices, followed by the index identifier, e.g. `$ci_my_idx`. - */ -internal const val PG_CUSTOM_IDX = "${PG_S}ci_" - -/** - * `$c_??`: The prefix used for constraints, followed by the identifier of the constraint. + * `$i`: The prefix used for index-names. The pattern is `{tablename}$i{ */ -internal const val PG_CONSTRAINT = "${PG_S}c_" +internal const val PG_IDX = "${PG_S}i" /** - * The name of the constraint above [next_version][PgColumn.next_version] (yearly partition). + * `$c`: The prefix used for constraints, followed by the identifier of the constraint. */ -internal const val PG_TN_NEXT_CONSTRAINT = "${PG_CONSTRAINT}nt" +internal const val PG_CONSTRAINT = "${PG_S}c" /** - * The name of the partition-constraint above [id][PgColumn.id]. + * `$c_nv`: The postfix of the history-constraint above [next_version][PgColumn.NEXT_VERSION] _(shifted partition)_. */ -internal const val PG_ID_CONSTRAINT = "${PG_CONSTRAINT}id" +internal const val PG_HISTORY_CONSTRAINT = "${PG_CONSTRAINT}nv" /** - * `$p_`: The prefix used for numerated partitions, the final value is `$p???` with `?` being `[0-9]`. + * `$c_fn`: The postfix of the distribution-constraint above [feature-number][PgColumn.FN]. */ -internal const val PG_PART = "${PG_S}p" +internal const val PG_DIST_CONSTRAINT = "${PG_CONSTRAINT}fn" /** - * `$head`: The separator used for HEAD-table index names: `{tableName}$head${indexName}`. + * `$h`: The prefix used for history-partitions. */ -internal const val PG_HEAD_IDX = "${PG_S}head${PG_S}" +internal const val PG_HISTORY_PARTITION = "${PG_S}h" /** - * `$y_`: The prefix used for yearly partitions of the TRANSACTIONS table, the final value is `$y????` with `?` being `[0-9]`. + * `$p`: The prefix used for distribution-partitions. */ -internal const val PG_YEAR = "${PG_S}y" +internal const val PG_DIST_PARTITION = "${PG_S}p" /** * The prefix used for all internal tables. */ internal const val PG_INTERNAL_PREFIX = Naksha.INTERNAL_PREFIX -internal const val NAKSHA_TXN_SEQ = "naksha_txn_seq" +internal const val NAKSHA_VERSION_SEQ = "naksha_version_seq" //internal const val NAKSHA_MAP_SEQ = "naksha_map_seq" //internal const val NAKSHA_COL_SEQ = "naksha_col_seq" internal const val MAX_POSTGRES_TOAST_TUPLE_TARGET = 32736 internal const val MIN_POSTGRES_TOAST_TUPLE_TARGET = 2048 -internal const val TRANSACTIONS_COL = Naksha.TRANSACTIONS_COL +internal const val TRANSACTIONS_COL = Naksha.TRANSACTIONS_COL_ID -internal const val NKC_TABLE = Naksha.TRANSACTIONS_COL -internal const val NKC_TABLE_ESC = "\"${Naksha.TRANSACTIONS_COL}\"" +internal const val NKC_TABLE = Naksha.TRANSACTIONS_COL_ID +internal const val NKC_TABLE_ESC = "\"${Naksha.TRANSACTIONS_COL_ID}\"" internal const val NKC_PARTITION_COUNT = "partitionCount" internal const val NKC_ID = "id" internal const val NKC_GEO_INDEX = "geoIndex" diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgAdminMap.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgAdminCatalog.kt similarity index 70% rename from here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgAdminMap.kt rename to here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgAdminCatalog.kt index ba0f885615..b4258a1b6d 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgAdminMap.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgAdminCatalog.kt @@ -13,14 +13,12 @@ import naksha.base.Platform.PlatformCompanion.logger import naksha.jbon.IDictReader import naksha.jbon.JbDictionary import naksha.model.* -import naksha.model.Naksha.NakshaCompanion.ADMIN_MAP -import naksha.model.Naksha.NakshaCompanion.ADMIN_MAP_NUMBER -import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_NUMBER +import naksha.model.Naksha.NakshaCompanion.ADMIN_CATALOG_ID +import naksha.model.Naksha.NakshaCompanion.ADMIN_CATALOG_FN import naksha.model.NakshaError.NakshaErrorCompanion.EXCEPTION -import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE import naksha.model.NakshaError.NakshaErrorCompanion.STORAGE_ID_MISMATCH -import naksha.model.objects.NakshaMap -import naksha.psql.PgColumn.PgColumnCompanion.headColumns +import naksha.model.objects.NakshaCatalog import kotlin.js.ExperimentalJsExport import kotlin.js.JsExport @@ -31,7 +29,7 @@ import kotlin.js.JsExport * @since 3.0.0 */ @JsExport -abstract class PgAdminMap internal constructor( +abstract class PgAdminCatalog internal constructor( /** * The storage to which this admin-map belongs. * @since 3.0.0 @@ -55,8 +53,7 @@ abstract class PgAdminMap internal constructor( * @since 3.0.0 */ upgrade: Boolean? -) : PgMap(storage, NakshaMap().withStorageId(storage.id).withId(ADMIN_MAP)), IDictReader { - +) : PgCatalog(storage, NakshaCatalog().withDatabaseId(storage.id).withId(ADMIN_CATALOG_ID)), IDictReader { /** * The page-size of the database (`current_setting('block_size')`). * @since 3.0.0 @@ -118,10 +115,10 @@ abstract class PgAdminMap internal constructor( val postgresVersion: NakshaVersion /** - * The OID of the transaction sequence. + * The OID of the current HEAD version. * @since 3.0.0 */ - val txnSequenceOid: Int + val versionSequenceOid: Int /** * The OID of the map-number sequence. @@ -156,7 +153,7 @@ abstract class PgAdminMap internal constructor( // Called from invokeInitStorage->initStorage, so within a lock! init { val id = config.id // storageId - val number = config.number // storageNumber + val number = config.databaseNumber // storageNumber val doOverride = config.override == true val doCreate = create ?: config.create val doUpgrade = upgrade ?: config.upgrade @@ -250,7 +247,7 @@ SELECT basics.*, procs.* FROM basics, procs; if (admin_schema_oid == null) { if (!doCreate) throw forbidden("Creation of admin-map needed, but forbidden by config") logger.info("Install Naksha admin-map in version $psql_version for storage $id / $number") - schemaOid = createAdminMap(conn, config, id, number, psql_version) + schemaOid = createAdminCatalog(conn, config, id, number, psql_version) } else { schemaOid = admin_schema_oid if (has_naksha_version != true) { @@ -265,7 +262,7 @@ SELECT basics.*, procs.* FROM basics, procs; var installed_version: NakshaVersion var installed_storage_id: String var installed_storage_number: Int64 - conn.execute("SELECT \"${ADMIN_MAP}\".naksha_version() AS v, \"${ADMIN_MAP}\".naksha_storage_id() AS id, \"${ADMIN_MAP}\".naksha_storage_number() AS n").fetch().use { cursor -> + conn.execute("SELECT \"${ADMIN_CATALOG_ID}\".naksha_version() AS v, \"${ADMIN_CATALOG_ID}\".naksha_storage_id() AS id, \"${ADMIN_CATALOG_ID}\".naksha_storage_number() AS n").fetch().use { cursor -> try { val v: Int64 = cursor["v"] installed_version = NakshaVersion(v) @@ -291,7 +288,7 @@ SELECT basics.*, procs.* FROM basics, procs; logger.info("The admin-map of '$id' is in version $installed_version, this library uses version $psql_version") if (doOverride) { logger.warn("Forcefully upgrade storage '$id' admin-map (current=$installed_version, new=$psql_version)") - upgradeAdminMap(conn, config, id, number, psql_version, admin_schema_oid, installed_version) + upgradeAdminCatalog(conn, config, id, number, psql_version, admin_schema_oid, installed_version) } else { if (installed_version > psql_version) { throw illegalState("The storage '$id' is in a newer version ($installed_version) than this library ($psql_version), access denied (otherwise we risk damaging the storage)") @@ -301,10 +298,10 @@ SELECT basics.*, procs.* FROM basics, procs; throw illegalState("The storage '$id' is in a newer version ($installed_version) that this library ($psql_version), access denied (there is a risk damaging the storage)") } logger.info("Upgrade Naksha admin-map from $installed_version to $psql_version for storage $id") - upgradeAdminMap(conn, config, id, number, psql_version, admin_schema_oid, installed_version) + upgradeAdminCatalog(conn, config, id, number, psql_version, admin_schema_oid, installed_version) } else if (doUpgrade){ logger.info("Upgrade Naksha admin-map from $installed_version to $psql_version for storage $id") - upgradeAdminMap(conn, config, id, number, psql_version, admin_schema_oid, installed_version) + upgradeAdminCatalog(conn, config, id, number, psql_version, admin_schema_oid, installed_version) } else { logger.info("In storage '$id' admin-map is in version $installed_version, this library is version $psql_version, but we should not upgrade the storage, and are okay working with the older version") } @@ -313,23 +310,15 @@ SELECT basics.*, procs.* FROM basics, procs; logger.info("The admin-map of '$id' is up-to-date: $psql_version") } } - // Always ensure internal collections have all currently-required columns on every startup. - // This is a lightweight migration: ALTER TABLE … ADD COLUMN IF NOT EXISTS is a no-op when - // the column already exists, and propagates automatically to all partitions in PostgreSQL. setSearchPath(conn) - for (c in listOf(transactions, books, catalogs)) { - c.headTable.addMissingCustomColumns(conn) - c.historyTable?.addMissingCustomColumns(conn) - c.metaTable?.addMissingCustomColumns(conn) - } - logger.info("Load OID of '$NAKSHA_TXN_SEQ' from admin schema (schema-oid=$schemaOid)") - val SQL = "SELECT oid FROM pg_class WHERE relnamespace = $schemaOid AND relname = '$NAKSHA_TXN_SEQ'" + logger.info("Load OID of '$NAKSHA_VERSION_SEQ' from admin schema (schema-oid=$schemaOid)") + val SQL = "SELECT oid FROM pg_class WHERE relnamespace = $schemaOid AND relname = '$NAKSHA_VERSION_SEQ'" conn.execute(SQL).fetch().use { cursor -> - txnSequenceOid = cursor["oid"] + versionSequenceOid = cursor["oid"] //mapNumberSequenceOid = cursor["map_oid"] //colNumberSequenceOid = cursor["col_oid"] } - logger.info("Storage ${config.id} / ${config.number} initialized, txn-seq-oid=$txnSequenceOid, commit") + logger.info("Storage ${config.id} / ${config.databaseNumber} initialized, txn-seq-oid=$versionSequenceOid, commit") conn.commit() } } @@ -346,7 +335,7 @@ SELECT basics.*, procs.* FROM basics, procs; * @return the admin schema `OID`. * @since 3.0.0 */ - protected abstract fun createAdminMap( + protected abstract fun createAdminCatalog( conn: PgConnection, config: PgConfig, storageId: String, @@ -367,7 +356,7 @@ SELECT basics.*, procs.* FROM basics, procs; * @param installedVersion the PSQL version that is currently installed that should be upgraded, if any (maybe only the schema exists). * @since 3.0.0 */ - protected abstract fun upgradeAdminMap( + protected abstract fun upgradeAdminCatalog( conn: PgConnection, config: PgConfig, storageId: String, @@ -387,7 +376,7 @@ SELECT basics.*, procs.* FROM basics, procs; */ fun getTxn(conn: PgConnection): Int64 { val QUERY = "SELECT currval($1) as txn" - val cursor = conn.execute(QUERY, arrayOf(txnSequenceOid)).fetch() + val cursor = conn.execute(QUERY, arrayOf(versionSequenceOid)).fetch() cursor.use { val txn: Int64 = cursor["txn"] return txn @@ -401,30 +390,29 @@ SELECT basics.*, procs.* FROM basics, procs; * @since 3.0.0 */ fun newTxn(conn: PgConnection): PgTxn { - val QUERY = "SELECT nextval($1) as txn, (extract(epoch from transaction_timestamp())*1000)::int8 as time" - val cursor = conn.execute(QUERY, arrayOf(txnSequenceOid)).fetch() + val QUERY = "SELECT nextval($1) as version, (extract(epoch from transaction_timestamp())*1000)::int8 as time" + val cursor = conn.execute(QUERY, arrayOf(versionSequenceOid)).fetch() cursor.use { - var txn: Int64 = cursor["txn"] - val txts: Int64 = cursor["time"] - var version = Version(txn) - val txInstant = Instant.fromEpochMilliseconds(txts.toLong()) + var number: Int64 = cursor["version"] + val time: Int64 = cursor["time"] + var version = Version(number) + val txInstant = Instant.fromEpochMilliseconds(time.toLong()) val txDate = txInstant.toLocalDateTime(TimeZone.UTC) if (version.year != txDate.year || version.month != txDate.monthNumber || version.day != txDate.dayOfMonth) { logger.info("Transaction counter is in wrong day") logger.info("Acquire advisory lock") conn.execute("SELECT pg_advisory_lock($1)", arrayOf(PgUtil.TXN_LOCK_ID)).close() try { - val c2 = conn.execute("SELECT nextval($1) as txn", arrayOf(txnSequenceOid)).fetch() + val c2 = conn.execute("SELECT nextval($1) as version", arrayOf(versionSequenceOid)).fetch() c2.use { - txn = c2["txn"] - version = Version(txn) + number = c2["version"] + version = Version(number) } if (version.year != txDate.year || version.month != txDate.monthNumber || version.day != txDate.dayOfMonth) { logger.info("Transaction counter is still at wrong day, rollover to next day") - // Rollover, we update sequence of the day. Start at seq=1 (auto() encodes action in bits 1-0). - version = Version.auto(txDate.year, txDate.monthNumber, txDate.dayOfMonth, Int64(1)) - txn = version.txn - conn.execute("SELECT setval($1, $2)", arrayOf(txnSequenceOid, txn + 4)).close() + version = Version.auto(txDate.year, txDate.monthNumber, txDate.dayOfMonth, Int64(0), Action.VERSION) + number = version.number + conn.execute("SELECT setval($1, $2)", arrayOf(versionSequenceOid, number + 4)).close() } logger.info("Release advisory lock") conn.execute("SELECT pg_advisory_unlock($1)", arrayOf(PgUtil.TXN_LOCK_ID)).close() @@ -442,74 +430,71 @@ SELECT basics.*, procs.* FROM basics, procs; // Doing a commit here is necessary to avoid that we get a lock to the txn sequence! // Even while sequences are normally not locked, it can happen under circumstances. conn.commit() - return PgTxn(txn, txts, version) + return PgTxn(number, time, version) } } - /** - * Returns the current map-number, so the last used one. - * @param conn the connection to use to access the database. - * @return the current _(last used)_ map-number. - * @since 3.0.0 - */ -// fun getMapNumber(conn: PgConnection): Int { -// val QUERY = "SELECT currval($1) as mapnum" -// val cursor = conn.execute(QUERY, arrayOf(mapNumberSequenceOid)).fetch() -// cursor.use { -// val number: Int = cursor["mapnum"] -// return number -// } -// } + protected val catalogCache = AtomicMap() /** - * Allocate a new map-number. - * @param conn the connection to use to access the database. - * @return the allocated map-number. - * @since 3.0.0 + * Store the given [PgCatalog] into the cache. + * @param catalog the catalog to store, must have a valid _HEAD_ state **and** must have a valid [TupleNumber]. + * @since 3.0 */ -// fun newMapNumber(conn: PgConnection): Int { -// val QUERY = "SELECT nextval($1) as mapnum" -// val cursor = conn.execute(QUERY, arrayOf(mapNumberSequenceOid)).fetch() -// cursor.use { -// val number: Int = cursor["mapnum"] -// return number -// } -// } - - // TODO: Implement the methods, then make them open, so we can override them for the JVM implementation - // We only want to cache in JVM, not within the database! - - protected val mapCache = AtomicMap() - protected val mapNumberById = AtomicMap() - - protected fun storeMap(map: PgMap) { - mapNumberById[map.id] = map.number - // TODO: Improve this, we should keep the PgMap that has the higher version! - mapCache[map.number] = map + protected fun cacheCatalog(catalog: PgCatalog) { + do { + val id = catalog.id + val newCatalog = catalog.head + val catalogNumber = newCatalog.catalogNumber + val newVersion = newCatalog.tupleNumber?.version ?: throw NakshaException( + ILLEGAL_STATE, + "Cannot store catalog '$id' in cache, missing `tupleNumber`" + ) + + val existing = catalogCache[catalogNumber] + val existingTn = existing?.head?.tupleNumber + val existingVersion: Int64? = if (existingTn != null && Action.fromVersion(existingTn.version) != Action.DELETE) existingTn.version else null + if (existingVersion != null && existingVersion > newVersion) { + logger.debug("Do not update catalog '$id', the existing version ($existingVersion) is newer than the new ($newVersion)") + break + } + if (existing != null) { + if (catalogCache.replace(catalogNumber, existing, catalog)) break + } else { + if (catalogCache.putIfAbsent(catalogNumber, catalog) == null) break + } + } while (true) } - protected fun invalidateMap(map: PgMap) { - mapCache.remove(map.number, map) - //mapNumberById.remove(map.id, map.number) + /** + * Remove the catalog with the given catalog-number from the cache. + */ + protected fun invalidateCatalog(catalog: PgCatalog, atomic: Boolean = true) { + if (atomic) catalogCache.remove(catalog.head.catalogNumber, catalog) else catalogCache.remove(catalog.head.catalogNumber) } /** - * Create a new [map][PgMap] using the given connection, and return it. + * Create a new custom [catalog][PgCatalog] using the given connection, and return it. * * ### Note - * Does not commit the given connection, therefore the map is not yet persisted, but can be used through the given connection. The method neither creates the corresponding entry in the collection's collection of the admin-map, it only creates the schema and collection-number sequence counter! - * - * - Throws [NakshaError.MAP_EXISTS] if such a map exists already. + * Does not commit the given connection, therefore the catalog _(aka schema)_ is not yet persisted, but can be used through the given connection. The method neither creates the corresponding entry in the collection's collection of the admin-catalog, it only creates the schema. * @param conn the connection to use to access the database. - * @param map the map to create. - * @return the created map. + * @param catalog the catalog to create. + * @throws NakshaException with [NakshaError.CATALOG_EXISTS] if a catalog with the same `id` exists, or if a catalog with a different `id`, but same feature-number exists _(hash collision)_. * @since 3.0.0 */ - fun createPgMap(conn: PgConnection, map: PgMap) { - if (Naksha.isInternalId(map.id)) throw NakshaException(ILLEGAL_ARGUMENT, "Can't create internal maps: ${map.id}") - conn.execute("CREATE SCHEMA IF NOT EXISTS ${map.quotedId}").close() - map.createPgCollection(conn, map.collections) // 0 - invalidateMap(map) + fun createPgCatalog(conn: PgConnection, catalog: PgCatalog) { + NakshaIdType.CATALOG.verify(catalog.id) + // TODO: We need to ensure that there is no other schema with the same feature-number: + // Write: `ALTER SCHEMA ${catalog.quotedId} SET SCHEMA OPTION 'featureNumber' 'stringifiedFN';` + // Read: + // SELECT nspname, nspconfig FROM pg_namespace WHERE nspname = '${catalog.id}'; + // SELECT nspname, split_part(kv, '=', 2) AS feature_number + // FROM pg_namespace, unnest(nspconfig) AS kv + // WHERE nspname = '${catalog.id}' AND split_part(kv, '=', 1) = 'featureNumber'; + conn.execute("CREATE SCHEMA IF NOT EXISTS ${catalog.quotedId}").close() + catalog.createPgCollection(conn, catalog.collections) // 0 + invalidateCatalog(catalog) } /** @@ -518,50 +503,43 @@ SELECT basics.*, procs.* FROM basics, procs; * ### Note * Does not commit the given connection, therefore the map is not yet physically deleted. The method neither deletes the corresponding entry from the collection's collection of the admin-map, it only drops the schema! * @param conn the connection to use to access the database. - * @param map the map to delete. + * @param catalog the map to delete. * @since 3.0.0 */ - fun deletePgMap(conn: PgConnection, map: PgMap) { - if (Naksha.isInternalId(map.id)) throw NakshaException(ILLEGAL_ARGUMENT, "Can't delete internal maps: ${map.id}") - conn.execute("DROP SCHEMA IF EXISTS ${map.quotedId} CASCADE").close() - invalidateMap(map) + fun deletePgCatalog(conn: PgConnection, catalog: PgCatalog) { + NakshaIdType.CATALOG.verify(catalog.id) + conn.execute("DROP SCHEMA IF EXISTS ${catalog.quotedId} CASCADE").close() + invalidateCatalog(catalog) } /** - * Returns the existing map with the given identifier; if any. + * Returns the existing catalog with the given identifier; if any. * @param conn the connection to use to access the database. - * @param id the map-id to query. + * @param id the catalog-id to query. * @return the map, if it exists; _null_ otherwise. * @since 3.0.0 */ - fun getPgMapById(conn: PgConnection?, id: String): PgMap? { - if (ADMIN_MAP == id) return this - val number = mapNumberById[id] - val existing = if (number != null) mapCache[number] else null - if (existing != null) return existing - if (conn == null) return null - - // Read from database - val outRows = PgColumnRows() - .withStorageNumber(storage.number) - .withMapNumber(ADMIN_MAP_NUMBER) - .withCollectionNumber(CATALOGS_COL_NUMBER) - .withDefaultDataEncoding(Naksha.DEFAULT_DATA_ENCODING) - .addColumns(headColumns) - val SQL = """SELECT ${outRows.names()} + fun getPgCatalogById(conn: PgConnection?, id: String): PgCatalog? { + if (ADMIN_CATALOG_ID == id) return this + val catalogNumber = Naksha.catalogNumber(id) + val existing = catalogCache[catalogNumber] + if (existing != null || conn==null) return existing + + val outRows = PgRows().withCollection(catalogs) + val SQL = """SELECT ${outRows.aliases()} FROM "naksha~admin".${catalogs.headTable.quotedName} WHERE id = $1 AND (version & 3) < 2""" val plan = conn.prepare(SQL, arrayOf(PgType.STRING.text)) - plan.execute(arrayOf(id)).fetch().use { - outRows.addAll(cursor = it) + plan.execute(arrayOf(id)).fetch().use { cursor -> + if (!outRows.read(cursor)) return null } if (outRows.size == 0) return null - val tuple = outRows[0] ?: return null + val tuple: Tuple = outRows[0] ?: return null Naksha.cache.store(tuple) - val nakshaMap = Naksha.decodeTuple(tuple).proxy(NakshaMap::class) - val pgMap = PgMap(storage, nakshaMap) - storeMap(pgMap) - return pgMap + val nakshaCatalog = tuple.decodeFeature(null).proxy(NakshaCatalog::class) + val pgCatalog = PgCatalog(storage, nakshaCatalog) + cacheCatalog(pgCatalog) + return pgCatalog } /** @@ -571,36 +549,27 @@ WHERE id = $1 AND (version & 3) < 2""" * @return the map, if it exists; _null_ otherwise. * @since 3.0.0 */ - fun getPgMapByNumber(conn: PgConnection?, number: Int): PgMap? { - if (ADMIN_MAP_NUMBER == number) return this - val existing = mapCache[number] + fun getPgCatalogByNumber(conn: PgConnection?, number: Int): PgCatalog? { + if (ADMIN_CATALOG_FN == number) return this + val existing = catalogCache[number] if (existing != null) return existing if (conn == null) return null // Read from database - val outRows = PgColumnRows() - .withStorageNumber(storage.number) - .withMapNumber(ADMIN_MAP_NUMBER) - .withCollectionNumber(CATALOGS_COL_NUMBER) - .withDefaultDataEncoding(Naksha.DEFAULT_DATA_ENCODING) - .addColumns(headColumns) - val SQL = """ - SELECT ${outRows.names()} - FROM "naksha~admin".${catalogs.headTable.quotedName} - WHERE fn = $1 AND (version & 3) < 2 - """.trimIndent() + val rows = PgRows().withCollection(catalogs) + val SQL = """SELECT ${rows.aliases()} +FROM "naksha~admin".${catalogs.headTable.quotedName} +WHERE fn = $1 AND (version & 3) < 2""" + setSearchPath(conn) val plan = conn.prepare(SQL, arrayOf(PgType.INT64.text)) - conn.execute(getSearchPath()) - plan.execute(arrayOf(number)).fetch().use { - outRows.addAll(cursor = it) - } - if (outRows.size == 0) return null - val tuple = outRows[0] ?: return null + plan.execute(arrayOf(number)).fetch().use { rows.readAll(it) } + if (rows.size == 0) return null + val tuple = rows[0] ?: return null Naksha.cache.store(tuple) - val nakshaMap = Naksha.decodeTuple(tuple).proxy(NakshaMap::class) - val pgMap = PgMap(storage, nakshaMap) - storeMap(pgMap) - return pgMap + val nakshaCatalog = tuple.decodeFeature(null).proxy(NakshaCatalog::class) + val pgCatalog = PgCatalog(storage, nakshaCatalog) + cacheCatalog(pgCatalog) + return pgCatalog } /** @@ -610,12 +579,12 @@ WHERE id = $1 AND (version & 3) < 2""" * @since 3.0.0 */ fun listPgMaps(conn: PgConnection): PgMapList - = PgMapList().withAll(mapCache.mapNotNull { it.value }) + = PgMapList().withAll(catalogCache.mapNotNull { it.value }) // TODO: This only reads the cache, but we need to load from database! - abstract fun getDataEncoding(feature: Any?, context: Any?): DataEncoding - + @Deprecated("Will be replaced with global books") abstract override fun getDictionary(id: String): JbDictionary? + @Deprecated("Will be replaced with global books") abstract override fun getEncodingDictionary(feature: Any?, context: Any?): JbDictionary? } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCatalog.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCatalog.kt new file mode 100644 index 0000000000..e84395c103 --- /dev/null +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCatalog.kt @@ -0,0 +1,327 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.psql + +import naksha.base.* +import naksha.base.Platform.PlatformCompanion.logger +import naksha.model.Action +import naksha.model.IWriteSession +import naksha.model.Naksha +import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL_ID +import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL_FN +import naksha.model.Naksha.NakshaCompanion.BOOKS_COL_ID +import naksha.model.Naksha.NakshaCompanion.BOOKS_COL_FN +import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_ID +import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_FN +import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL_ID +import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL_FN +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaException +import naksha.model.PgTx +import naksha.model.TupleNumber +import naksha.model.objects.NakshaCollection +import naksha.model.objects.NakshaCatalog +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.model.objects.XyzMembers +import naksha.model.objects.XyzProcessors +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent +import kotlin.js.JsExport +import kotlin.jvm.JvmField + +/** + * A map _(aka catalog)_ contains collections. + * @since 3.0 + */ +@JsExport +open class PgCatalog internal constructor( + /** + * The reference to the storage (effectively the same as the database, for now). + * @since 3.0.0 + */ + open val storage: PgStorage, + + /** + * The HEAD state of the catalog. + * @since 3.0.0 + */ + nakshaCatalog: NakshaCatalog, + + /** + * The custom catalog-identifier. + * @since 3.0 + */ + val id: String = nakshaCatalog.id, + + /** + * The catalog-number of the catalog, actually the same as the feature-number of the [NakshaCatalog] feature. + * @since 3.0 + */ + val catalogNumber: Int = Naksha.catalogNumber(id), +) { + /** + * The map-identifier quoted optionally in double quotes. + * @since 3.0 + */ + @JvmField + val quotedId = quoteIdent(id) + + /** + * The _HEAD_ state of the map. + * + * ### Note + * If the map is deleted, this value stays unmodified, because the [PgCatalog] will be removed from caching. However, if only the _HEAD_ state of the map is modified, so basically an `UPDATE` is done, the _HEAD_ reference is replaced on-the-fly. + * @since 3.0 + */ + val headRef = AtomicNonNullRef(nakshaCatalog) + + /** + * Reads [headRef]. + * @see [headRef] + * @since 3.0 + */ + val head: NakshaCatalog + get() = headRef.get() + + private var _collections: PgCollection? = null + + /** + * The collection's collection of the map _(`naksha~collections` aka `0`)_. + * @since 3.0 + * @see [createPgCollection] + * @see [getPgCollectionById] + * @see [getPgCollectionByNumber] + * @see [deletePgCollection] + */ + val collections: PgCollection + get() { + var c = _collections + if (c == null) { + val nakshaCollection = NakshaCollection(COLLECTIONS_COL_ID, id) + .withXyzMembers() + .withXyzIndices() + c = PgCollection(this, nakshaCollection) + _collections = c + } + return c + } + + protected val collectionCache = AtomicMap() + + /** + * Store the given [PgCollection] into the cache. + * @param collection the collection to store, must have a valid _HEAD_ state **and** must have a valid [TupleNumber]. + * @since 3.0 + */ + protected fun cacheCollection(collection: PgCollection) { + do { + val id = collection.id + val newCollection = collection.head + val collectionNumber = newCollection.collectionNumber + val newVersion = newCollection.tupleNumber?.version ?: throw NakshaException( + ILLEGAL_STATE, + "Cannot store collection '$id' in cache, missing `tupleNumber`" + ) + + val existing = collectionCache[collectionNumber] + val existingTn = existing?.head?.tupleNumber + val existingVersion: Int64? = if (existingTn != null && Action.fromVersion(existingTn.version) != Action.DELETE) existingTn.version else null + if (existingVersion != null && existingVersion > newVersion) { + logger.debug("Do not update collection '$id', the existing version ($existingVersion) is newer than the new ($newVersion)") + break + } + if (existing != null) { + if (collectionCache.replace(collectionNumber, existing, collection)) break + } else { + if (collectionCache.putIfAbsent(collectionNumber, collection) == null) break + } + } while (true) + } + + internal fun invalidateCollection(collection: PgCollection) { + collectionCache.remove(collection.head.collectionNumber, collection) + } + + /** + * Returns the `search_path` so that this schema _(catalog)_ is on the top, followed by `naksha~admin`, `topology`, `hint_plan`, `public`. + * @return the `search_path` so that this schema _(catalog)_ is on the top, followed by `naksha~admin`, `topology`, `hint_plan`, `public`. + */ + fun searchPath(): String = if (this is PgAdminCatalog) { + "\"naksha~admin\", topology, hint_plan, public" + } else { + "${quotedId}, \"naksha~admin\", topology, hint_plan, public" + } + + /** + * Sets the `search_path` for the current transaction, so until `commit` or `rollback`. + * + * Actually: + * ```sql + * SET search_path = ${searchPath()} + * ``` + * + * @param conn the connection where to set the search path. + * @since 3.0 + * @see [searchPath] + */ + fun setSearchPath(conn: PgConnection) { + conn.execute("SET search_path = ${searchPath()}").close() + } + + /** + * Create a new [collection][PgCollection] using the given connection, and return it. + * + * ### Note + * - This method does not commit the given connection, therefore the collection is not yet persisted, but can be used through the given connection. + * - The method does not insert the corresponding entry into the collection's collection, this must be done upfront by the caller. + * @param conn the connection to use to access the database. + * @param collection the collection to create. + * @return the created map. + * @since 3.0 + */ + open fun createPgCollection(conn: PgConnection, collection: PgCollection) { + // Ensure that all tables and indices are created in the correct schema! + setSearchPath(conn) + + val headTable = collection.headTable + headTable.create(conn) + for (index in collection.headIndices) headTable.createIndex(conn, index) + + val history = collection.historyTable + history.create(conn) + // Note: We do not create history partitions, because doing so would require a session. + // The reason is, that we need to know the version to know which history partition the HEAD will be moved into. + // history.createPartition(conn, collection.historyPartitionNumberOf(tx.version.number)) + // for (index in collection.historyIndices) headTable.createIndex(conn, index) + + // TODO: Fix cache by adding 2nd level cache in session, we only want to update in 2nd level cache and move to 1rst level when committed! + invalidateCollection(collection) + } + + /** + * Refresh the cached information of this collection, mainly updates the history tables. + * - Throws [NakshaError.COLLECTION_NOT_FOUND], if the collection has been deleted. + * @param conn the connection to query the database; if _null_, a new data connection is acquired, used, and released. + * @since 3.0.0 + */ + private fun refreshPgCollection(conn: PgConnection, collection: PgCollection): PgCollection { + // TODO: Implement me, but only if needed! + throw UnsupportedOperationException() + } + + /** + * Deletes a collection. + * @param conn the connection to use to access the database. + * @param collection the collection to delete. + * @since 3.0.0 + */ + open fun deletePgCollection(conn: PgConnection, collection: PgCollection) { + setSearchPath(conn) + val builder = StringBuilder() + val head = collection.headTable + builder.append("DROP TABLE IF EXISTS ${head.quotedName} CASCADE;\n") + + val history = collection.historyTable + builder.append("DROP TABLE IF EXISTS ${history.quotedName} CASCADE;\n") + + val SQL = builder.toString() + conn.execute(SQL).close() + logger.info("Dropped collection '{}' with collection-number {}", collection.id, collection.head.collectionNumber) + + // TODO: Fix cache by adding 2nd level cache in session, we only want to update in 2nd level cache and move to 1rst level when committed! + invalidateCollection(collection) + } + + /** + * Returns the existing collection with the given identifier; if any. + * @param conn the connection to use to access the database. + * @param id the collection-id to query. + * @return the collection, if it exists; _null_ otherwise. + * @since 3.0.0 + */ + fun getPgCollectionById(conn: PgConnection?, id: String): PgCollection? { + if (this is PgAdminCatalog) { + return when (id) { + COLLECTIONS_COL_ID -> collections + TRANSACTIONS_COL_ID -> transactions + CATALOGS_COL_ID -> catalogs + BOOKS_COL_ID -> books + else -> null + } + } + if (id == COLLECTIONS_COL_ID) return collections + val collectionNumber = Naksha.collectionNumber(id) + val existing = collectionCache[collectionNumber] + if (existing != null || conn == null) return existing + + // Read from database + setSearchPath(conn) + val TABLE = collections.headTable.quotedName + val ID = collections.column(Id) + val SQL = "SELECT * FROM $TABLE WHERE $ID = $1" + val plan = conn.prepare(SQL, arrayOf(PgType.STRING.text)) + val rows = PgRows().withCollection(collections) + plan.execute(arrayOf(id)).fetch().use { + rows.readAll(cursor = it) + } + if (rows.size == 0) return null + val tuple = rows[0] ?: return null + Naksha.cache.store(tuple) + val nakshaCollection = tuple.decodeFeature(null).proxy(NakshaCollection::class) + val pgCollection = PgCollection(this, nakshaCollection) + // TODO: Fix cache by adding 2nd level cache in session, we only want to update in 2nd level cache and move to 1rst level when committed! + cacheCollection(pgCollection) + return pgCollection + } + + /** + * Returns the existing collection with the given number; if any. + * @param conn the connection to use to access the database. + * @param number the collection-number to query. + * @return the collection, if it exists; _null_ otherwise. + * @since 3.0.0 + */ + fun getPgCollectionByNumber(conn: PgConnection?, number: Int): PgCollection? { + if (this is PgAdminCatalog) { + return when (number) { + COLLECTIONS_COL_FN -> collections + TRANSACTIONS_COL_FN -> transactions + CATALOGS_COL_FN -> catalogs + BOOKS_COL_FN -> books + else -> null + } + } + if (number == COLLECTIONS_COL_FN) return collections + val existing = collectionCache[number] + if (existing != null || conn == null) return existing + + // Read from database + setSearchPath(conn) + val TABLE = collections.headTable.quotedName + val SQL = "SELECT * FROM $TABLE WHERE $FN = $1" + val plan = conn.prepare(SQL, arrayOf(PgType.INT64.text)) + val rows = PgRows().withCollection(collections) + plan.execute(arrayOf(number)).fetch().use { rows.readAll(it) } + if (rows.size == 0) return null + val tuple = rows[0] ?: return null + Naksha.cache.store(tuple) + val nakshaCollection = tuple.decodeFeature(null).proxy(NakshaCollection::class) + val pgCollection = PgCollection(this, nakshaCollection) + // TODO: Fix cache by adding 2nd level cache in session, we only want to update in 2nd level cache and move to 1rst level when committed! + cacheCollection(pgCollection) + return pgCollection + } + + /** + * Returns a list of all existing collections in the map, excluding the collections' collection. + * @param conn the connection to use to access the database. + * @param map the map in which to search for the collection. + * @return the list of existing collections, _(empty, when no collections exist)_. + * @since 3.0.0 + */ + fun listPgCollections(conn: PgConnection, map: PgCatalog): PgCollectionList { + val list = PgCollectionList() + // TODO: Implement me! + return list + } +} diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCollection.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCollection.kt index ff9a3d8aa4..8183f8d74f 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCollection.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCollection.kt @@ -1,15 +1,37 @@ package naksha.psql import naksha.base.* +import naksha.base.fn.Fn1 import naksha.model.* +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaError.NakshaErrorCompanion.INTERNAL_ERROR +import naksha.model.objects.IndexList +import naksha.model.objects.Member +import naksha.model.objects.MemberList +import naksha.model.objects.MemberType.MemberType_C.BYTE_ARRAY +import naksha.model.objects.MemberType.MemberType_C.INT64 +import naksha.model.objects.MemberType.MemberType_C.STRING +import naksha.model.objects.MemberType.MemberType_C.TUPLE_NUMBER import naksha.model.objects.NakshaCollection +import naksha.model.objects.StandardMembers.StandardMembers_C.Feature +import naksha.model.objects.StandardMembers.StandardMembers_C.GlobalBookFeatureNumber +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion +import naksha.model.objects.StandardMembers.StandardMembers_C.Tn import naksha.model.objects.StoreMode -import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent +import naksha.psql.PgColumn.PgColumn_C.EXTENDED +import naksha.psql.PgColumn.PgColumn_C.EXTERNAL +import naksha.psql.PgColumn.PgColumn_C.MAIN +import naksha.psql.PgColumn.PgColumn_C.PLAIN import kotlin.js.JsExport +import kotlin.js.JsName import kotlin.jvm.JvmField /** - * A collection is a set of database tables, that together form a logical feature store. This lower level implementation supports methods to create the collection physically (so the whole set of tables), to refresh the information about the collection, drop the tables, to add, or remove indices at runtime, aso. + * A collection is a set of database tables that together form a logical feature store. This lower level implementation supports methods to create the collection physically (so the whole set of tables), to refresh the information about the collection, drop the tables, to add, or remove indices at runtime, aso. + * + * This is a wrapper around the [NakshaCollection]. * * @since 3.0 */ @@ -17,129 +39,239 @@ import kotlin.jvm.JvmField @JsExport open class PgCollection internal constructor( /** - * The map in which the collection is located. + * The catalog in which the collection is located. * @since 3.0 */ - val map: PgMap, + val catalog: PgCatalog, /** * The HEAD state of the collection. * @since 3.0 */ nakshaCollection: NakshaCollection, +) { /** - * The map-id. + * The custom-identifier of the collection. * @since 3.0 */ - val id: String = nakshaCollection.id, + @JvmField + val id: String = nakshaCollection.id /** - * The map-number. + * The collection-number of the collection, actually the same as the feature-number of the [NakshaCollection] feature. * @since 3.0 */ - val number: Int = nakshaCollection.number -) { + @JvmField + val collectionNumber: Int = Naksha.collectionNumber(id) + /** - * The weak-reference to this [PgCollection], should be used when the collection should be cached. + * The amount of bit the [next-version][PgColumn.NEXT_VERSION] should be shifted right to calculate the history-partition. * @since 3.0 + * @see [PgHistoryTable] */ - @Suppress("LeakingThis") - val weakRef = WeakRef(this) - + @JvmField + val shift: Int = nakshaCollection.shift + /** - * The storage in which the collection is located. + * Returns the partition index of the [PgHistoryPartition] in which features can be found, that are modified in the given version. + * @param version the version in which features are modified. + * @return the partition index of the [PgHistoryPartition] into which _HEAD_ features will be moved, when modified in the given version. * @since 3.0 */ - val storage: PgStorage - get() = map.storage + fun historyPartitionNumberOf(version: Int64): Int = (version shr shift).toInt() /** - * The _HEAD_ state of the collection. - * - * ### Note - * If the collection is deleted, this value stays unmodified, because the [PgCollection] will be removed from caching. However, if only the _HEAD_ state of the collection is modified, so basically an `UPDATE` is done, the _HEAD_ reference is replaced on-the-fly. + * Convert the given member into a [PgColumn], only fails for the standard member [Tn]. + * @param member the member to convert, must not me [Tuple-Number][Tn]. + * @param index the real index in the physical table at which to place the member. + * @return the [PgColumn] for the member. + * @throws NakshaException with error [INTERNAL_ERROR], if the given member is [Tn]. * @since 3.0 */ - val headRef = AtomicNonNullRef(nakshaCollection) + private fun fromMember(member: Member, index: Int): PgColumn { + if (Tn.name == member.name) throw NakshaException(INTERNAL_ERROR, "The tuple-number can't be converted using fromMember") + val memberName = member.name + // These mandatory members get special storage handling. + when (memberName) { + GlobalBookFeatureNumber.name -> return PgColumn(index, memberName, INT64, "STORAGE $PLAIN") + Id.name -> return PgColumn(index, memberName, STRING, "STORAGE $PLAIN") + Feature.name -> return PgColumn(index, memberName, BYTE_ARRAY, "STORAGE $EXTERNAL") + } + // The storage is by default defined by data-type + val memberType = member.dataType + return when (memberType) { + BYTE_ARRAY, TUPLE_NUMBER -> PgColumn(index, memberName, STRING, "STORAGE $EXTENDED") + STRING -> PgColumn(index, memberName, STRING, "COLLATE \"C\" STORAGE $MAIN") + else -> PgColumn(index, memberName, STRING, "STORAGE $MAIN") + } + } /** - * Reads [headRef]. - * @see [headRef] - * @since 3.0 + * Read the [members][NakshaCollection.members] from the given [NakshaCollection] and turn them into a [PgColumn] list. If the members of the given [NakshaCollection] and not yet [ordered by index][MemberList.isSortedByIndex], then the method will invoke a [sort by type][MemberList.sortByDataTypeAndAssignIndex]. + * @param nakshaCollection the collection from which to extract the [PgColumn]'s. + * @return an array with all extracted [PgColumn]'s. */ - val head: NakshaCollection - get() = headRef.get() + private fun generateColumns(nakshaCollection: NakshaCollection): Array { + val members: MemberList = nakshaCollection.useMembers() + if (!members.isSortedByIndex()) { + members.sortByDataTypeAndAssignIndex() + } + var i = 0 + // We split tuple-number into `fn` and `version`, therefore we need size + 1. + return Array(members.size + 1) { + when (it) { + // The first three members are fixed to: + 0 -> PgColumn.FN + 1 -> PgColumn.VERSION + 2 -> PgColumn.NEXT_VERSION + else -> { + val col: PgColumn + var member = members[i++] ?: throw NakshaException(ILLEGAL_STATE, "Member #${i-1} is null") + var name = member.name + // Tn is already added as `fn` and `version`, and `next_version` is as well already added. + while (name==Tn.name || name==NextVersion.name) { + member = members[i++] ?: throw NakshaException(ILLEGAL_STATE, "Member #${i-1} is null") + name = member.name + } + col = fromMember(member, it) + col + } + } + } + } /** - * The storage class of the collection. + * The columns to expect in the table. + * + * The columns are filled from the members-book and vice versa. Most of the time the columns match the members, but there are details in which they can differ, i.e. for the tuple-number. + * @since 3.0 */ - var storageClass: PgStorageClass = PgStorageClass.Unknown - internal set + @JvmField + val columns: Array = generateColumns(nakshaCollection) /** - * The columns present in the HEAD (and META/DELETED) tables of this collection. - * - * Always includes the full set of default head columns. If the collection declares additional - * members that map to known [PgColumn] entries (e.g. `pn`, `pt`, `gv`) those columns are - * appended so that every physically-present column is covered in a single list. + * Join the identities of all [columns], separated by comma, optionally filtered by the given filter. + * @param prefix an optional prefix to be added in front of each column being joined. + * @param toIdent an optional convertion lambda that turns the column into a string; if it returns `null`, the column is skipped. + * @return a comma separated list of [ident][PgColumn.ident] strings. * @since 3.0 */ - val effectiveHeadColumns: List - get() { - val members = head.members ?: return PgColumn.headColumns - val byName = PgColumn.allColumnsByName - val base = PgColumn.headColumns - val extras = mutableListOf() - for (m in members) { - if (m == null) continue - val col = byName[m.name] - if (col != null && col !in base && col !in extras) extras.add(col) + fun joinColumns(prefix: String? = null, toIdent: Fn1? = null): String { + val sb = StringBuilder() + for (column in columns) { + val ident: String? = if (toIdent != null) toIdent.call(column) else column.ident + if (ident == null) continue + if (sb.isNotEmpty()) sb.append(", ") + if (prefix != null) sb.append(prefix) + sb.append(ident) + } + return sb.toString() + } + + private fun indicesFor(nakshaCollection: NakshaCollection, onHead: Boolean): Array { + return Array(nakshaCollection.indices?.size ?: 0) { i -> + // We know that when this method is called, indices must not be null! + val indices: IndexList = nakshaCollection.indices!! + val index = indices[i] ?: throw NakshaException(ILLEGAL_STATE, "Index #$i must not be null") + val indexName = index.name + + val nakshaOn = index.on + val on: ArrayList = ArrayList(nakshaOn.size) + on@ for (i in 0 ..< nakshaOn.size) { + val name = nakshaOn[i] ?: throw NakshaException(ILLEGAL_STATE, "Index '$indexName->on[$i]' must not be null") + if (onHead && NextVersion.name == name) continue // no NEXT_VERSION in HEAD + for (column in columns) { + if (column.name == name) { + on.add(column) + continue@on + } + } + throw NakshaException(ILLEGAL_ARGUMENT, "Index '$indexName->on[$i]' refers to member '$name', but no such member exists") } - return if (extras.isEmpty()) base else base + extras + val nakshaInclude = index.include + val include: ArrayList? + if (!nakshaInclude.isNullOrEmpty()) { + include = ArrayList(nakshaInclude.size) + include@ for (i in 0 ..< nakshaInclude.size) { + val name = nakshaInclude[i] ?: throw NakshaException(ILLEGAL_STATE, "Index '$indexName->include[$i]' must not be null") + if (onHead && NextVersion.name == name) continue // no NEXT_VERSION in HEAD + for (column in columns) { + if (column.name == name) { + include.add(column) + continue@include + } + } + throw NakshaException(ILLEGAL_ARGUMENT,"Index '$indexName->include[$i]' refers to member '$name', but no such member exists") + } + } else { + include = null + } + PgIndex(indexName, on.toTypedArray(), include?.toTypedArray() ?: emptyArray()) } + } /** - * The columns present in the HISTORY tables of this collection. - * - * Mirrors [effectiveHeadColumns] but includes [PgColumn.next_version] for HISTORY. + * The indices of the HEAD table. * @since 3.0 */ - val effectiveHistoryColumns: List - get() { - val members = head.members ?: return PgColumn.allColumns - val byName = PgColumn.allColumnsByName - val base = PgColumn.allColumns - val extras = mutableListOf() - for (m in members) { - if (m == null) continue - val col = byName[m.name] - if (col != null && col !in base && col !in extras) extras.add(col) - } - return if (extras.isEmpty()) base else base + extras - } + @JvmField + val headIndices: Array = indicesFor(nakshaCollection, onHead = true) /** - * The subset of [PgColumn.copyIntoHistoryColumns] that actually exist in this collection's tables. - * Used when copying HEAD rows into HISTORY to avoid referencing absent optional columns. + * The indices of the HISTORY table. * @since 3.0 */ - val effectiveCopyIntoHistoryColumns: List - get() { - val effective = effectiveHeadColumns.toSet() - return PgColumn.copyIntoHistoryColumns.filter { it in effective } - } + @JvmField + val historyIndices: Array = indicesFor(nakshaCollection, onHead = false) /** - * The subset of [PgColumn.updateColumns] that actually exist in this collection's tables. - * Used in UPDATE CTEs to avoid referencing absent optional columns. + * Tests if the history should be stored. * @since 3.0 */ - val effectiveUpdateColumns: List - get() { - val effective = effectiveHeadColumns.toSet() - return PgColumn.updateColumns.filter { it in effective } - } + val storeHistory: Boolean + get() = head.storeHistory === StoreMode.ON + + /** + * Tests if deleted [Tuple] should be kept in _HEAD_; if not they are automatically purged when being deleted. + * @since 3.0 + */ + val storeDeleted: Boolean + get() = head.storeDeleted === StoreMode.ON + + /** + * The weak-reference to this [PgCollection], should be used when the collection should be cached. + * @since 3.0 + */ + @Suppress("LeakingThis") + val weakRef = WeakRef(this) + + /** + * The storage in which the collection is located. + * @since 3.0 + */ + val storage: PgStorage + get() = catalog.storage + + /** + * The _HEAD_ state of the collection. + * @since 3.0 + */ + val headRef = AtomicNonNullRef(nakshaCollection) + + /** + * Reads [headRef]. + * @see [headRef] + * @since 3.0 + */ + val head: NakshaCollection + get() = headRef.get() + + /** + * The storage class of the collection. + */ + var storageClass: PgStorageClass = JsEnum.getDefined(nakshaCollection.storageClass, PgStorageClass::class) ?: PgStorageClass.Unknown + internal set /** * The amount of performance partitions. @@ -148,9 +280,9 @@ open class PgCollection internal constructor( get() = head.partitions /** - * The `HEAD` table, so where to store features or transactions into. + * The `HEAD` table, so where to store features into. * - * If this is an ordinary table, that can be partitioned using [PgPlatform.partitionNumber] above the [PgColumn.id], except when this is a `TRANSACTION` collection, then the partitioning is done by [Version.year], extracted from [PgColumn.txn]. + * If this is an ordinary table, that can be partitioned using [PgPlatform.partitionNumber] above the [feature-number][PgColumn.FN]. * * Writing directly into partitions, or reading from them, is discouraged, but in some cases necessary to improve performance drastically. In AWS the speed of every [single-flow](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-network-bandwidth.html) connection is limited to 5 Gbps (10 Gbps when being in the same [cluster placement group](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/placement-strategies.html#placement-groups-cluster)), but still always limited. When the PostgresQL database and the client both have higher bandwidth, then multiple parallel connection need to be used, for example to saturate the HERE temporary or consistent store bandwidth of 200 Gbps, between 20 and 40 connections are needed. * @@ -158,199 +290,74 @@ open class PgCollection internal constructor( * - The `TRANSACTION` table is not designed for ultra-high throughput, but rather as a storage that allows easy and fast garbage collection and to query ordered by transaction numbers or _sequence numbers_. * - Internal tables do not allow to add or remove indices, while ordinary consumer tables do allow this. */ - var headTable: PgHead - internal set + @JvmField + val headTable: PgHeadTable = PgHeadTable(this) /** - * The history can be disabled fully or temporary. When disabled fully, no history tables are created, in that case this property will be _null_. + * The history table of the collection. * - * If the history tables were created, they are always partitioned by the year of `txn_next`. Each yearly table is partitioned again the same way that [HEAD][headTable] is partitioned, not doing this would create a bottleneck when modifying features in parallel, because then the parallel connections would have a congestion in the history. The history therefore managed the same way as [HEAD][headTable], so using the [PgPlatform.partitionNumber] above the `feature.id`. + * The history table is always partitioned by the [next-version][PgColumn.NEXT_VERSION], which is shifted right by a [shift]. Each [history partition][PgHistoryPartition] is partitioned again the same way that [HEAD][headTable] is partitioned. Not doing this would create a bottleneck when modifying features in parallel, because then the parallel connections would have a congestion in the history, when moving old data into history. The history is therefore managed the same way as [HEAD][headTable], so using the [PgPlatform.partitionNumber] above the [feature-number][PgColumn.FN]. */ - var historyTable: PgHistory? - internal set + @JvmField + val historyTable: PgHistoryTable = PgHistoryTable(this) /** - * An optional metadata table, never partitioned. This table is used to as internal storage for metadata, like statistics, calculated by background jobs and other information like this. It can be used as well by applications, and is accessible from outside, but does not have any history or track changes. + * Returns the [PgColumn] that corresponds to the given member. + * @param member the [Member] for which to return the column. + * @return the [PgColumn] that with the given name; `null` if no such column exists. + * @since 3.0 */ - var metaTable: PgTable? - internal set + @JsName("getColumnByMember") + fun column(member: Member): PgColumn? = column(member.name) /** - * Tests if this is an internal collection. Internal collections have some limitation, for example it is not possible to add or drop indices, nor can they be created through the normal [create] method. They are basically immutable by design, but the content can be read and modified to some degree. - * - * **Warning**: Internal tables may have further limitations, even about the content, for example the transaction log only allows to mutate `tags` for normal external clients. Internal clients may perform other mutations, e.g. the internal _sequencer_ is allowed to set the `seqNumber`, `seqTs`, `geo` and `geo_ref` columns, additionally it will update the feature counts, when necessary. However, for normal clients the transaction log is immutable, and the _sequencer_ will only alter the transactions ones in their lifetime. + * Returns the [PgColumn] that has the given name. + * @param name the name of the column to return. + * @return the [PgColumn] that with the given name; `null` if no such column exists. + * @since 3.0 */ - val internal: Boolean - get() = id.startsWith("naksha~") - - init { - val partitions = if (nakshaCollection.partitions == 1) 0 else nakshaCollection.partitions - storageClass = PgStorageClass.of(nakshaCollection.storageClass) - @Suppress("LeakingThis") - headTable = PgHead(this, storageClass, partitions) - historyTable = if (nakshaCollection.storeHistory == StoreMode.OFF) null else PgHistory(headTable) - metaTable = if (nakshaCollection.storeMeta == StoreMode.OFF) null else PgMeta(headTable) + @JsName("getColumnByName") + fun column(name: String): PgColumn? { + for (column in columns) { + if (column.name == name) return column + } + return null } /** - * Add the before and after triggers. - * @param sql The SQL API. - * @param id The collection identifier. - * @param schema The schema name. - * @param schemaOid The object-id of the schema to look into. + * If this is an internal collection. + * + * Internal collections have some limitation, for example it is not possible to add or drop indices, nor can they be created through normal methods. They are basically immutable by design, but the content can be read and modified to some degree. */ - @Deprecated(message = "Needs to be fixed before used", level = DeprecationLevel.ERROR) - private fun collectionAttachTriggers(sql: PgConnection, id: String, schema: String, schemaOid: Int) { - var triggerName = id + "_before" - var rows = sql.execute("SELECT tgname FROM pg_trigger WHERE tgname = $1 AND tgrelid = $2", arrayOf(triggerName, schemaOid)) - if (rows.isRow()) { - val schemaQuoted = quoteIdent(schema) - val tableNameQuoted = quoteIdent(id) - val triggerNameQuoted = quoteIdent(triggerName) - sql.execute( - """CREATE TRIGGER $triggerNameQuoted BEFORE INSERT OR UPDATE ON ${schemaQuoted}.${tableNameQuoted} -FOR EACH ROW EXECUTE FUNCTION naksha_trigger_before();""" - ) - } - - triggerName = id + "_after" - rows = sql.execute("SELECT tgname FROM pg_trigger WHERE tgname = $1 AND tgrelid = $2", arrayOf(triggerName, schemaOid)) - if (rows.isRow()) { - val schemaQuoted = quoteIdent(schema) - val tableNameQuoted = quoteIdent(id) - val triggerNameQuoted = quoteIdent(triggerName) - sql.execute( - """CREATE TRIGGER $triggerNameQuoted AFTER INSERT OR UPDATE OR DELETE ON ${schemaQuoted}.${tableNameQuoted} -FOR EACH ROW EXECUTE FUNCTION naksha_trigger_after();""" - ) - } - } + @JvmField + val internal: Boolean = id.startsWith("naksha~") /** - * Diff the [prev] and [next] collection definitions and apply the resulting schema changes (ADD/DROP COLUMN, CREATE/DROP custom index) to all variant tables (HEAD, HISTORY, DELETED, META). - * - * Members: - * - Same name + same `dataType` → no-op. - * - Same name + different `dataType` → throws [NakshaError.ILLEGAL_ARGUMENT] (type changes are not supported). - * - Added → `ALTER TABLE ADD COLUMN` on each root variant. - * - Removed → requires [force] = `true`; otherwise throws [NakshaError.ILLEGAL_ARGUMENT]. With `force`, emits `ALTER TABLE DROP COLUMN` on each root variant. - * - * Custom indexes: - * - Identity is `(name, type, on, include, unique)`. Any difference → drop + create. - * - * Only root tables are touched; partition tables inherit `ADD/DROP COLUMN` via Postgres declarative partitioning. + * Ensures that the [PgHistoryPartition] for the given version exists. * + * **Note**: If the current cache does not confirm that the corresponding history table exists, we can simply create it with `IF EXISTS` clause. It will not harm, when it exists already, but prevent yet another roundtrip. As we _(for now)_ have decided that columns and indices are immutable, we do not need to update old partition tables, and we can cache only the latest history table. This may change over time, because ones we start adding the feature that data can be imported, we may need to manage history partitions that are older than the latest _(current)_ table that we normally only write into. However, until that is done, this function can be kept simple . + * @param version the version to be written. * @since 3.0 */ - internal fun applyMembersAndIndexes( - conn: PgConnection, - prev: NakshaCollection, - next: NakshaCollection, - force: Boolean - ) { - diffMembers(conn, prev, next, force) - diffIndexes(conn, prev, next) - } - - private fun diffMembers(conn: PgConnection, prev: NakshaCollection, next: NakshaCollection, force: Boolean) { - val prevMembers = prev.members - val nextMembers = next.members - val prevByName = mutableMapOf() - val nextByName = mutableMapOf() - // Mandatory columns are always present in the DB and never appear in members lists, - // so we skip them here entirely. - val mandatoryNames = PgColumn.mandatoryMembers.map { it.name }.toSet() - if (prevMembers != null) for (m in prevMembers) if (m != null && m.name !in mandatoryNames) prevByName[m.name] = m - if (nextMembers != null) for (m in nextMembers) if (m != null && m.name !in mandatoryNames) nextByName[m.name] = m - - // Type-change check. - for ((name, nm) in nextByName) { - val pm = prevByName[name] ?: continue - if (pm.dataType != nm.dataType) { - throw naksha.model.illegalArg( - "Change of dataType for member '$name' is not supported (was ${pm.dataType}, requested ${nm.dataType})" - ) - } - } - - // Added. - for ((name, nm) in nextByName) { - if (prevByName.containsKey(name)) continue - val pgIdent = "\"${PgCustomMemberValues.pgColumnName(name)}\"" - val pgType = PgCustomMemberValues.pgSqlTypeFor(nm.dataType) - for (root in mutableRootTables()) { - val sql = "ALTER TABLE ${root.quotedName} ADD COLUMN IF NOT EXISTS $pgIdent $pgType" - conn.execute(sql).close() - } - } - - // Removed. - for ((name, _) in prevByName) { - if (nextByName.containsKey(name)) continue - if (!force) { - throw naksha.model.illegalArg( - "Member '$name' is being removed from collection '$id', but Write.force is false. " + - "Set Write.force = true to allow ALTER TABLE DROP COLUMN." - ) - } - val pgIdent = "\"${PgCustomMemberValues.pgColumnName(name)}\"" - for (root in mutableRootTables()) { - val sql = "ALTER TABLE ${root.quotedName} DROP COLUMN IF EXISTS $pgIdent" - conn.execute(sql).close() - } - } - } - - private fun diffIndexes(conn: PgConnection, prev: NakshaCollection, next: NakshaCollection) { - val prevIdx = prev.indices - val nextIdx = next.indices - val prevByName = mutableMapOf() - val nextByName = mutableMapOf() - // Internal (mandatory) indices are always managed by the storage; skip them in the diff. - if (prevIdx != null) for (ci in prevIdx) if (ci != null && !ci.internal) prevByName[ci.name] = ci - if (nextIdx != null) for (ci in nextIdx) if (ci != null && !ci.internal) nextByName[ci.name] = ci - - // Removed (or changed): drop on every root. - for ((name, pi) in prevByName) { - val ni = nextByName[name] - if (ni == null || !customIndexEquals(pi, ni)) { - for (root in mutableRootTables()) root.dropCustomIndex(conn, name) - } - } - - // Added (or changed): create on every root. - for ((name, ni) in nextByName) { - val pi = prevByName[name] - if (pi == null || !customIndexEquals(pi, ni)) { - for (root in mutableRootTables()) root.createCustomIndex(conn, ni) - } - } + fun prepareWrite(version: Int64) { + val partitionNumber = version shr shift + // TODO: Check if this partition exists, if not, create it, cache it, and initiate the write. } - private fun customIndexEquals(a: naksha.model.objects.Index, b: naksha.model.objects.Index): Boolean { - if (a.name != b.name) return false - if (a.type != b.type) return false - if (a.unique != b.unique) return false - if (!listsEqual(a.on, b.on)) return false - if (!listsEqual(a.include, b.include)) return false - return true - } - - private fun listsEqual(a: naksha.base.StringList?, b: naksha.base.StringList?): Boolean { - if (a == null && b == null) return true - if (a == null || b == null) return a?.isEmpty() ?: b?.isEmpty() ?: true - if (a.size != b.size) return false - for (i in 0 until a.size) { - if (a[i] != b[i]) return false - } - return true - } - - private fun mutableRootTables(): List { - val result = mutableListOf() - result.add(headTable) - historyTable?.let { result.add(it) } - metaTable?.let { result.add(it) } - return result + /** + * Verify the given new _HEAD_ state, ensure that none of the following values is modified: + * - [NakshaCollection.members] - Ensure that they result in the same [columns]. + * - [NakshaCollection.indices] - Ensure that they result in the same [headIndices] and [historyIndices]. + * - [NakshaCollection.shift] - The shift must not change, because it impacts partitioning. + * - [NakshaCollection.id] - Must match [id] and the resulting calculated [collectionNumber] must match as well. + * - [NakshaCollection.storageClass] - Must match [storageClass], changing the storage class is not allowed. + * - [NakshaCollection.partitions] - Must patch [partitions], changing the number of partitions is not allowed. + * + * Actually all other values can be changes, with only some having an impact to this object. + * @param newHead the new _HEAD_ state to be verified. + * @throws NakshaException with error [ILLEGAL_STATE] if the columns or indices in the given `newHead` have been changed. + */ + fun verifyNewHeadState(newHead: NakshaCollection) { + // TODO: Implement me! } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumn.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumn.kt index b0dd74279a..1c4bf2e48b 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumn.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumn.kt @@ -2,118 +2,99 @@ package naksha.psql -import naksha.base.Int64 -import naksha.base.JsEnum -import naksha.model.DataEncoding +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT +import naksha.model.NakshaException import naksha.model.objects.Member import naksha.model.objects.MemberType -import naksha.model.request.query.MetaColumn +import naksha.model.objects.MemberType.MemberType_C.BYTE_ARRAY +import naksha.model.objects.MemberType.MemberType_C.INT64 +import naksha.model.objects.MemberType.MemberType_C.STRING +import naksha.model.objects.MemberType.MemberType_C.TUPLE_NUMBER +import naksha.model.objects.StandardMembers.StandardMembers_C.Feature +import naksha.model.objects.StandardMembers.StandardMembers_C.GlobalBookFeatureNumber +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion +import naksha.model.objects.StandardMembers.StandardMembers_C.Tn import kotlin.js.JsExport +import kotlin.js.JsName import kotlin.js.JsStatic import kotlin.jvm.JvmField import kotlin.jvm.JvmStatic -import kotlin.reflect.KClass -import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B64 -import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B128 /** - * A column descriptor for database columns, especially helpful when reading columns using `SELECT * FROM table`. + * A column descriptor for database columns. * - * The extras are used to add constraints, and the storage-method: - * - **PLAIN** prevents either compression or out-of-line storage. - * - **EXTENDED** allows both compression and out-of-line storage. - * - **EXTERNAL** allows out-of-line storage but not compression. - * - **MAIN** allows compression but not out-of-line storage. Actually, out-of-line storage will still be performed for such columns, but only as a last resort when there is no other way to make the row small enough to fit on a page. + * The [extra] is used to add constraints, and the storage-method: + * - [PLAIN] prevents either compression or out-of-line storage. + * - [EXTENDED] allows both compression and out-of-line storage. + * - [EXTERNAL] allows out-of-line storage but not compression. + * - [MAIN] allows compression but not out-of-line storage. Actually, out-of-line storage will still be performed for such columns, but only as a last resort when there is no other way to make the row small enough to fit on a page. * * The TOAST code will compress and/or move field values out-of-line until the row value is shorter than TOAST_TUPLE_TARGET bytes. * - * **Note**: We order the columns by intention, to minimize the storage size. The _bytea_ columns will be GZIP compressed on demand by the client storing the data (see [DataEncoding]). Note that in some cases this is not useful, for example, when a `TWKB` geometry or reference-point is given, it is often so small, that compression would increase the size. The general rule is, that anything being smaller than 100 byte, should not be compressed, which only leaves [feature] and [attachment] as compression candidates. We know that [feature] is stored GZIP compressed, therefore the only candidate left is [attachment]. So, we store [feature] _EXTERNAL_, [attachment] _EXTENDED_, and everything else as _PLAIN_ to keep it available for fast access. [tags] is stored as raw `jsonb` so the column itself is directly indexable (GIN); Postgres will TOAST/compress it automatically. - * * See [storage-toast.html](https://www.postgresql.org/docs/current/storage-toast.html). */ @JsExport -class PgColumn : JsEnum() { - private fun checkThatNotDefined(column: PgColumn) { - check(!isDefined) { "Changing ${column.name} is not allowed!" } - } - - private var _i: Int = -1 - +data class PgColumn( /** - * The index in the columns list of a Naksha standard table. A value from 0 to n-1 if this column is part of Naksha standard tables, with n being the amount of columns. A value of `-1`, if this column is no standard column. + * The index of the column in the table. + * @since 3.0 */ - var i: Int - get() = _i - set(value) { - checkThatNotDefined(this) - _i = value - } + @JvmField + val index: Int, /** * The name of the column. + * @since 3.0 */ - val name: String - get() = super.text - - private var _type: PgType? = null + @JvmField + val name: String, /** - * The type of the column. + * The type of the column provided as [MemberType]. + * @since 3.0 */ - var type: PgType - get() { - return _type ?: PgType.NULL - } - set(value) { - checkThatNotDefined(this) - _type = value - } - - private var _extra: String? = null + @JvmField + val memberType: MemberType, /** * Optional extras for the definition, for example "NOT NULL". + * @since 3.0 */ - var extra: String? - get() = _extra - set(value) { - checkThatNotDefined(this) - _extra = value - } + @JvmField + val extra: String? = null, /** - * The stringified version, the same as returned by [toString]. + * The type of the column. + * @since 3.0 */ - var sqlDefinition: String = "" - get() { - // TODO: KotlinCompilerBug - How can field ever be null? - @Suppress("UselessCallOnNotNull") - if (field.isNullOrEmpty()) { - field = "$name $type" + if (extra != null) " $extra" else "" - } - return field - } - private set - - private var _ident: String? = null + @JvmField + val pgType: PgType = PgType.ofMemberType(memberType), /** - * The SQL quoted identifier. - * - * The same result (just slower) can be archived using `PgUtil.quoteIdent(col.name)`. + * The SQL quoted identifier, the same result (just slower) can be archived using `PgUtil.quoteIdent(col.name)`. * @return the SQL quoted identifier (with optional double quotes). + * @see PgUtil.quoteIdent + */ + @JvmField + val ident: String = PgUtil.quoteIdent(name), + + /** + * The SQL code to added into a `CREATE TABLE` statements. + */ + @JvmField + val sql: String = "$ident $pgType" + if (extra != null) " $extra" else "" +) { + /** + * Create a new column with the same states, just a different index. + * @param index the new index. + * @param source the column to copy with all other attributes. + * @since 3.0 */ - val ident: String - get() { - var ident = this._ident - if (ident == null) { - ident = PgUtil.quoteIdent(this.name) - this._ident = ident - } - return ident - } + @JsName("reindex") + constructor(index: Int, source: PgColumn) : this(index, source.name, source.memberType, source.extra, source.pgType, source.ident) - companion object PgColumnCompanion { + companion object PgColumn_C { /** * Prevents either compression or out-of-line storage. This is the only possible strategy for columns of non-TOAST-able data types. * @since 3.0 @@ -138,772 +119,55 @@ class PgColumn : JsEnum() { */ const val MAIN = "MAIN" - /** - * Returns the columns instance for the given name. - * @param columnName the column name. - * @return the column enumeration value. - * @since 3.0 - */ - @JvmStatic - @JsStatic - fun of(columnName: String): PgColumn = get(columnName, PgColumn::class) - - /** - * The epoch timestamp in millisecond when the [tuple][naksha.model.Tuple] was produced, which is the last time the feature was modified. - * - * **Optional** — `NULL` is allowed; NULL means the timestamp was not recorded. - * @since 3.0 - */ - @JvmField - @JsStatic - val updated_at = def(PgColumn::class, "updated_at") { self -> - self._i = 3 - self._type = PgType.INT64 - } - - /** - * The epoch timestamp in millisecond when the [feature][naksha.model.objects.NakshaFeature] was originally created. If the value is _null_, this means this [tuple][naksha.model.Tuple] is the initial state of the [feature][naksha.model.objects.NakshaFeature], so the value is the same as [updated_at]. - * @since 3.0 - */ - @JvmField - @JsStatic - val created_at = def(PgColumn::class, "created_at") { self -> - self._i = 4 - self._type = PgType.INT64 - } - - /** - * The epoch timestamp in millisecond when the [feature][naksha.model.objects.NakshaFeature] was modified last by the author. If the value is _null_, this means this [tuple][naksha.model.Tuple] was changed by the author, so the value is the same as [updated_at]. - * @since 3.0 - */ - @JvmField - @JsStatic - val author_ts = def(PgColumn::class, "author_ts") { self -> - self._i = 5 - self._type = PgType.INT64 - } - - /** - * If this has a custom value, otherwise _null_. - * @since 3.0 - */ - @JvmField - @JsStatic - val cv0 = def(PgColumn::class, "cv0") { self -> - self._i = 7 - self._type = PgType.DOUBLE - } - - /** - * If this has a custom value, otherwise _null_. - * @since 3.0 - */ - @JvmField - @JsStatic - val cv1 = def(PgColumn::class, "cv1") { self -> - self._i = 8 - self._type = PgType.DOUBLE - } - - /** - * If this has a custom value, otherwise _null_. - * @since 3.0 - */ - @JvmField - @JsStatic - val cv2 = def(PgColumn::class, "cv2") { self -> - self._i = 9 - self._type = PgType.DOUBLE - } - - /** - * If this has a custom value, otherwise _null_. - * @since 3.0 - */ - @JvmField - @JsStatic - val cv3 = def(PgColumn::class, "cv3") { self -> - self._i = 10 - self._type = PgType.DOUBLE - } + /** Constant for the name of the feature-number column. */ + const val FN_NAME = "_fn" /** - * The unique hash of this [tuple][naksha.model.Tuple] (state), calculated by the storage using the static [Metadata.calculateHash] method. + * The feature-number. * - * **Optional** — `NULL` is allowed. + * Together with [VERSION], forms the primary identification of a tuple within a collection. The lower 16 bits of this value are used as the partition key for distribution partitioning (see [naksha.model.Naksha.featureNumber]). * @since 3.0 */ @JvmField @JsStatic - val hash = def(PgColumn::class, "hash") { self -> - self._i = 11 - self._type = PgType.INT - } + val FN = PgColumn(0, FN_NAME, INT64, "STORAGE $PLAIN NOT NULL") - /** - * The binary [HERE tile-key][naksha.geo.HereTile.intKey] of the reference-point of the [tuple][naksha.model.Tuple] (state). This is calculated using the static [Metadata.calculateHereTile] method. - * - * **Optional** — `NULL` is allowed; NULL means no reference point / tile is known. - * @since 3.0 - */ - @JvmField - @JsStatic - val here_tile = def(PgColumn::class, "here_tile") { self -> - self._i = 12 - self._type = PgType.INT - } - - /** - * The `change-count`. - * - * **Optional** — `NULL` is allowed. - * @since 3.0 - */ - @JvmField - @JsStatic - val cc = def(PgColumn::class, "cc") { self -> - self._i = 13 - self._type = PgType.INT - } - - /** - * The feature-number — the per-collection identifier of the feature this tuple belongs to. - * - * Together with [version], forms the primary identification of a tuple within a collection. The lower 16 bits of this value are used as the partition key for distribution partitioning (see [naksha.model.Naksha.featureNumber]). - * @since 3.0 - */ - @JvmField - @JsStatic - val fn = def(PgColumn::class, "fn") { self -> - self._i = 0 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN NOT NULL" - } + /** Constant for the name of the version column. */ + const val VERSION_NAME = "_version" /** * The version (with action in the lower 2 bits) of this tuple. * - * Together with [fn], forms the primary identification of a tuple within a collection. See [naksha.model.Version] for the layout. - * @since 3.0 - */ - @JvmField - @JsStatic - val version = def(PgColumn::class, "version") { self -> - self._i = 1 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN NOT NULL" - } - - /** - * The next version of the tuple (with action in the lower 2 bits). - * - * Stored only in _HISTORY_ tables. In _HEAD_ this is intrinsically [HEAD][naksha.model.Version.HEAD] (the row is the head state); the column is omitted from HEAD DDL. - * - * For a tombstone state (final row of a feature's lifetime), this equals the row's own `version`. - * @since 3.0 - */ - @JvmField - @JsStatic - val next_version = def(PgColumn::class, "next_version") { self -> - self._i = 2 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN" // prevents out-of-line storage - } - - /** - * The [tuple-number][naksha.model.TupleNumber] of the _BASE_ state upon which a [three-way-merge](https://en.wikipedia.org/wiki/Merge_(version_control)#Three-way_merge) was done, using the [128-bit encoding][B128]. - * - * This value is set, when a client reads the _HEAD_ state of a feature, then modifies it into some _NEW_ state, and tries to save its changes, but meanwhile other clients did the same, and a conflict arises. The changes the client did are based upon an older _HEAD_, which we will name _BASE_, and do not reflect the changes the other clients did meanwhile. - * - * In this situation an automatic [three-way-merge](https://en.wikipedia.org/wiki/Merge_(version_control)#Three-way_merge) can be done. The changes of the other clients are calculated as difference between _HEAD_ and _BASE_, and the changes the client did is calculated as difference between _NEW_ and _BASE_. Then the difference are added to a patch. This can fail, if both clients changes the same properties, what will cause a conflict. If successful, the patch is applied to _BASE_ and will produce a new _NEW2_ state, that actually contains the changes of the client plus the changes of the other clients. This _NEW2_ state can be written into the storage; it references the old _HEAD_ as its previous tuple. This can fail again, if others were faster in doing the same, but is an idempotent operation, and can simply be repeated until either conflicting or solved. - * - * To document that this happened, the shared _BASE_ state is referred vai `base_tn`. - * - * This technically allows to calculate back, what the client actually modified. For this, the difference between _HEAD_ and _BASE_ is calculated, and then the difference between the previous tuple and _BASE_ is subtracted, resulting in a patch that can be applied to _BASE_ to receive the original _NEW_ state the client had in memory and wanted to persist. This difference will as well document which properties were changed by the client. - * - * The encoding stores, in order, Big-Endian encoded: - * - feature-number: 64 - * - version (with action in lower 2 bits): 64 - * @since 3.0 - * @see [B128] - */ - @JvmField - @JsStatic - val base_tn = def(PgColumn::class, "base_tn") { self -> - self._i = 24 - self._type = PgType.BYTE_ARRAY - self._extra = "STORAGE $PLAIN" // prevents either compression or out-of-line storage - } - - /** - * The feature-id. - * - * **Conditionally mandatory**: - * - When `fn < 0` (named feature, MSB set): `id` MUST NOT be `NULL` — it carries the user-supplied string identifier. - * - When `fn >= 0` (anonymous / numeric feature): `id` MUST be `NULL` — the logical id is `fn` decimally encoded. - * @since 3.0 - */ - @JvmField - @JsStatic - val id = def(PgColumn::class, "id") { self -> - self._i = 14 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage; NOT NULL enforced via CHECK constraint - } - - /** - * The application-id of the application that produced a [tuple][naksha.model.Tuple]. - * @since 3.0 - */ - @JvmField - @JsStatic - val app_id = def(PgColumn::class, "app_id") { self -> - self._i = 15 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" - } - - /** - * The author that takes ownership for the [tuple][naksha.model.Tuple]. - * @since 3.0 - */ - @JvmField - @JsStatic - val author = def(PgColumn::class, "author") { self -> - self._i = 16 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" - } - - /** - * The stringified [Guid][naksha.model.Guid] from where the feature originates. - * - * Whenever a feature is copied from a foreign storage, map, or collection, or the feature-id is changed in an update, the `origin` refers to the originating feature. - * - * When a client decides to read the _HEAD_ state of a feature, and then to merge a foreign feature logically into it, the client should set `origin` to the foreign feature it merged into this one. This allows to track this, and to rebase the new feature, should the original one being updated. - * - * Eventually `origin` is used to perform rebasing. A rebase is a complex [three-way-merge](https://en.wikipedia.org/wiki/Merge_(version_control)#Three-way_merge). Assume a feature is modified, then it is possible to search for all features in other storages, maps, and collections that have `origin` set to this feature. Actually, we only search for features having the same originating feature, so who have an `origin` starting with the ID of the originating feature. When found, a rebase can be started. - * - * To perform an actual rebase, the client that will do the rebase, will first find the latest state of the originating feature, _ORIGIN-HEAD_. Then it will read the state that `origin` refers to as _ORIGIN-BASE_, calculating a difference between the _ORIGIN-HEAD_ and the _ORIGIN-BASE_, being _ORIGIN-DIFF_. Now the history of the rebase feature need to read, until the moment is found, where the origin was set. This is the _REBASE-BASE_ state. Then the latest version of the feature is looked up, and read as _REBASE-HEAD_. This allows to calculate the difference, being _REBASE-DIFF_. - * - * Now, we have two differences, the _ORIGIN-DIFF_, describing what was changed in the origin since the feature was forked, and _REBASE-DIFF_, which describes what was change in the to rebase collection, since the feature was forked. This allows to add both differences, resulting in a _REBASE-PATCH_ or a conflict, if both collections modified the same properties. Some of these conflicts can be automatically solved by one wins over the other, but this is out of scope of this description. Eventually, when successfully created the _REBASE_PATCH_, this patch can be applied to _REBASE-BASE_ to produce a _REBASE-NEW_ state, that has _REBASE-HEAD_ as precedence state. When this is applied, the `origin` need to be updated to the one to which the rebase was performed. - * - * This was a simplified description, but should allow to understand the basic concepts and meaning of this property. - * - * The `origin` is sticky, the value is kept until updated, the feature is forked again, or the ID is changed. - * @since 3.0 - */ - @JvmField - @JsStatic - val origin = def(PgColumn::class, "origin") { self -> - self._i = 17 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * The stringified [Guid][naksha.model.Guid] of the feature into which this feature was integrated. - * - * This value is set in the following situations: - * - **Join**: If multiple features are deleted, and then replaced with a single feature, then the features that are deleted will have `action` set to `DELETED` and the `target` will refer to the feature into which they are joined. The target feature will have the `action` set to `JOINED` to indicate, that there are deleted features, which target this one. - * @since 3.0 - */ - @JvmField - @JsStatic - val target = def(PgColumn::class, "target") { self -> - self._i = 18 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * A customer feature-type, [type][naksha.model.objects.NakshaFeature.type] of the [feature][naksha.model.objects.NakshaFeature], _null_ if it matches the [default-type of the collection][naksha.model.objects.NakshaCollection.typeDefaultValue]. - * @since 3.0 - */ - @JvmField - @JsStatic - val ft = def(PgColumn::class, "ft") { self -> - self._i = 19 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * A custom string, _null_ if not used. - * @since 3.0 - */ - @JvmField - @JsStatic - val cs0 = def(PgColumn::class, "cs0") { self -> - self._i = 20 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * A custom string, _null_ if not used. - * @since 3.0 - */ - @JvmField - @JsStatic - val cs1 = def(PgColumn::class, "cs1") { self -> - self._i = 21 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * A custom string, _null_ if not used. - * @since 3.0 - */ - @JvmField - @JsStatic - val cs2 = def(PgColumn::class, "cs2") { self -> - self._i = 22 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * A custom string, _null_ if not used. - * @since 3.0 - */ - @JvmField - @JsStatic - val cs3 = def(PgColumn::class, "cs3") { self -> - self._i = 23 - self._type = PgType.STRING - self._extra = "STORAGE $PLAIN COLLATE \"C\"" // prevents either compression or out-of-line storage - } - - /** - * The tags of the [tuple][naksha.model.Tuple], stored as raw `jsonb`. - * - * By default ([naksha.model.objects.MemberType.SET]) this is a JSON array of unique strings - * ([naksha.model.TagList]), persisted unmodified so the element order is preserved. When the - * tags member is declared as [naksha.model.objects.MemberType.TAGS] or - * [naksha.model.objects.MemberType.TAGS_FROM_ARRAY], it is a JSON object - * ([naksha.model.TagMap]) instead. - * @since 3.0 - */ - @JvmField - @JsStatic - val tags = def(PgColumn::class, "tags") { self -> - self._i = 25 - self._type = PgType.JSONB - } - - /** - * The reference-point of the [feature][naksha.model.objects.NakshaFeature]. - * @since 3.0 - */ - @JvmField - @JsStatic - val ref_point = def(PgColumn::class, "ref_point") { self -> - self._i = 26 - self._type = PgType.BYTE_ARRAY - self._extra = "STORAGE $EXTERNAL" - } - - /** - * The geometry of the [feature][naksha.model.objects.NakshaFeature]. - * @since 3.0 - */ - @JvmField - @JsStatic - val geo = def(PgColumn::class, "geo") { self -> - self._i = 27 - self._type = PgType.BYTE_ARRAY - self._extra = "STORAGE $EXTERNAL" - } - - /** - * The serialized [feature][naksha.model.objects.NakshaFeature]. - * - * **Mandatory** — MUST NOT be `NULL`. Every tuple must carry a serialized feature blob. - * Not indexable; encoding is configurable (default: JBON2_GZIP). - * @since 3.0 - */ - @JvmField - @JsStatic - val feature = def(PgColumn::class, "feature") { self -> - self._i = 28 - self._type = PgType.BYTE_ARRAY - self._extra = "STORAGE $EXTERNAL NOT NULL" - } - - /** - * An arbitrary binary attachment. - * @since 3.0 - */ - @JvmField - @JsStatic - val attachment = def(PgColumn::class, "attachment") { self -> - self._i = 29 - self._type = PgType.BYTE_ARRAY - self._extra = "STORAGE $EXTERNAL" - } - - /** - * The global-book-number that references the JBON2 global dictionary entry needed to decode - * the [feature] blob when global-book encoding is used. Assigned by the sequencer. - * - * **Optional** — `NULL` when no global book is in use for this tuple. - * @since 3.0 - */ - @JvmField - @JsStatic - val gbn = def(PgColumn::class, "gbn") { self -> - self._i = 6 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN" - } - - /** - * Publisher-assigned gap-free sequential number ordered by **visibility** (not execution order). - * After publish-number `N` comes `N+1` without holes, regardless of transaction commit order. - * Assigned by an external publisher component. `NULL` until published. - * @since 3.0 - */ - @JvmField - @JsStatic - val pn = def(PgColumn::class, "pn") { self -> - self._i = 30 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN" - } - - /** - * Millisecond epoch timestamp at which the publisher recognised and sequenced this transaction, - * assigning it its [pn]. `NULL` until published. - * @since 3.0 - */ - @JvmField - @JsStatic - val pt = def(PgColumn::class, "pt") { self -> - self._i = 31 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN" - } - - /** - * HERE-internal global version number assigned by HERE's global sequencing infrastructure. - * `NULL` until the global sequencer has processed the transaction. - * @since 3.0 - */ - @JvmField - @JsStatic - val gv = def(PgColumn::class, "gv") { self -> - self._i = 32 - self._type = PgType.INT64 - self._extra = "STORAGE $PLAIN" - } - - /** - * All columns being used with Naksha, ordered by type alignment group for minimal PostgreSQL - * tuple padding: - * 1. INT64 (8-byte): fn, version, next_version, updated_at, created_at, author_ts, gbn - * 2. FLOAT64 (8-byte): cv0, cv1, cv2, cv3 - * 3. INT32 (4-byte): hash, here_tile, cc - * 4. STRING (var): id, app_id, author, origin, target, ft, cs0, cs1, cs2, cs3 - * 5. BYTE_ARRAY (var): base_tn, tags, ref_point, geo, feature, attachment - * - * The SPECIAL columns ([pn], [pt], [gv]) follow the same INT64 slot rule but are - * only physically present when explicitly declared; they live in [allColumnsByName] only. + * Together with [FN], forms the primary identification of a tuple within a collection. See [naksha.model.Version] for the layout. * @since 3.0 */ @JvmField @JsStatic - val allColumns = listOf( - // INT64 group - fn, version, next_version, - updated_at, created_at, author_ts, - gbn, - // FLOAT64 group - cv0, cv1, cv2, cv3, - // INT32 group - hash, here_tile, cc, - // STRING group - id, app_id, author, origin, target, ft, - cs0, cs1, cs2, cs3, - // BYTE_ARRAY group - base_tn, tags, ref_point, geo, feature, attachment, - ) + val VERSION = PgColumn(1, VERSION_NAME, INT64, "STORAGE $PLAIN NOT NULL") /** - * Columns present in a _HEAD_ table — same as [allColumns] except for [next_version], which is intrinsically [HEAD][naksha.model.Version.HEAD] in HEAD rows and therefore not stored. - * @since 3.0 + * Constant for the name of the next-version column. + * @see [naksha.model.objects.StandardMembers.NextVersion] */ - @JvmField - @JsStatic - val headColumns = allColumns.filter { it !== next_version } + const val NEXT_VERSION_NAME = "_nv" /** - * All columns indexed by name, for fast lookup. - * Includes [allColumns] plus the optional SPECIAL columns ([pn], [pt], [gv]) that are - * only physically present when a collection explicitly declares them as members. + * The next-version (with action in the lower 2 bits) of this tuple, only available in the history. * @since 3.0 */ @JvmField @JsStatic - val allColumnsByName: Map = (allColumns + listOf(pn, pt, gv)).associateBy { it.name } - - /** - * The minimal mandatory columns for a HEAD / META / DELETED table: - * `fn`, `version`, `id`, `feature`, and `gbn`. - * - * Used when [naksha.model.objects.NakshaCollection.members] is an **empty list** (explicitly set to `[]`) - * to create a lean table with no optional built-in columns. - * @since 3.0 - */ - @JvmField - @JsStatic - val mandatoryColumns: List = listOf(fn, version, id, feature, gbn) - - /** - * The minimal mandatory columns for a HISTORY table: - * [mandatoryColumns] plus [next_version] (which is the HISTORY partition key). - * @since 3.0 - */ - @JvmField - @JsStatic - val mandatoryHistoryColumns: List = listOf(fn, version, next_version, id, feature, gbn) - - /** - * The names of all HEAD database columns, as comma-separated list. - * @since 3.0 - */ - @JsStatic - @JvmField - val headColumnNames = headColumns.joinToString(",") { it.name } - - /** - * The names of all database columns, as comma separated list. - * @since 3.0 - */ - @JsStatic - @JvmField - val allColumnNames = allColumns.joinToString(",") { it.name } - - /** - * The placeholders when inserting all columns, like $1, $2, ... - * @since 3.0 - */ - @JsStatic - @JvmField - val allColumnPlaceholders = allColumns.joinToString(",") { "\$${(it.i + 1)}" } - - /** - * The type names for the values of all columns, for example `int8` for a column being [Int64]. - * @since 3.0 - */ - @JsStatic - @JvmField - val allColumnTypeNames = Array(allColumns.size) { allColumns[it].type.text } - - /** - * The array type names for the values of all columns, for example `int8[]` for a column being [Int64]. - * - * To be used when multiple rows need to be transferred into the database, for example, creating a plan for all columns: - * ```kotlin - * val SQL = """WITH input AS ( - * SELECT * FROM - * UNNEST(${allColumnPlaceholders}) AS t($allColumnNames) - * ), ...""" - * val plan = conn.prepare(SQL, allColumnArrayTypeNames) - * val values = Array(n) { ... } // an array of arrays - * val cursor = plan.execute(values) - * ``` - * @since 3.0 - */ - @JsStatic - @JvmField - val allColumnArrayTypeNames = Array(allColumns.size) { allColumns[it].type.text+"[]" } - - /** - * All columns that are needed when we copy a feature from _HEAD_ into _HISTORY_, so all except for: - * - [next_version] - * @since 3.0 - */ - @JvmField - @JsStatic - val copyIntoHistoryColumns = listOf( - updated_at, created_at, author_ts, - cv0, cv1, cv2, cv3, - hash, here_tile, cc, - fn, version, base_tn, // removed: next_version, prev_tn - id, app_id, author, origin, target, ft, - cs0, cs1, cs2, cs3, - tags, ref_point, geo, feature, attachment, - gbn, - pn, pt, gv, - ) - - /** - * The names of all database columns, as comma separated list. - * @since 3.0 - */ - @JsStatic - @JvmField - val copyIntoHistoryColumnNames = copyIntoHistoryColumns.joinToString(",") { it.name } - - /** - * All columns that we copy from the user data, when we update a row. - * - * This excludes the columns that need updates: - * - [cc] - we need to increment change-count - * @since 3.0 - */ - @JvmField - @JsStatic - val updateColumns = listOf( - updated_at, created_at, author_ts, - cv0, cv1, cv2, cv3, - hash, here_tile, // removed: cc - base_tn, // removed: fn, version (PK columns; not set via updates), next_version (HEAD has none) - id, app_id, author, origin, target, ft, - cs0, cs1, cs2, cs3, - tags, ref_point, geo, feature, // removed: attachment (needs special handling) - gbn, - ) - - /** - * The names of all database columns, as comma separated list. - * @since 3.0 - */ - @JsStatic - @JvmField - val updateColumnsNames = updateColumns.joinToString(",") { it.name } - - /** - * All columns that we copy, when we create a tombstone state (deleted). - * - * In that case we copy from _HEAD_ into a temporary CTE table, then further to _HISTORY_ and/or _SHADOW_, but we need to update some columns, therefore this excludes the columns that need updates: - * - [next_tn] - will become the current [tn] to signal tombstone state _(dead-end)_ - * - [tn] - must be updated to match current `version`, with action bits set to DELETED (this is the `final_tn`) - * - [fn] - copied from the HEAD row directly - * - [base_tn] - needs to be set to `null` - * @since 3.0 - */ - @JvmField - @JsStatic - val tombstoneColumns = listOf( - updated_at, created_at, author_ts, - cv0, cv1, cv2, cv3, - hash, here_tile, // removed: cc - // removed: fn, version, next_version, and base_tn, - id, app_id, author, origin, target, ft, - cs0, cs1, cs2, cs3, - tags, ref_point, geo, feature, attachment, - gbn, - ) - - init { - // This is only self-check code. - for ((i, col) in allColumns.withIndex()) { - check(i == col.i) { "Invalid columns, column '${col.name}' should be at index ${col.i}, but found at $i" } - } - } - - /** - * Maps a [PgColumn] to its equivalent [MemberType], used when generating [Member] objects - * for mandatory and default member lists. - */ - internal fun pgTypeToMemberType(col: PgColumn): MemberType = when { - col === geo || col === ref_point -> MemberType.SPATIAL - else -> when (col.type) { - PgType.INT64 -> MemberType.INT64 - PgType.DOUBLE -> MemberType.FLOAT64 - PgType.INT -> MemberType.INT32 - PgType.SHORT -> MemberType.INT16 - PgType.FLOAT -> MemberType.FLOAT32 - PgType.BOOLEAN -> MemberType.BOOLEAN - PgType.STRING -> MemberType.STRING - PgType.BYTE_ARRAY -> MemberType.BYTE_ARRAY - else -> MemberType.STRING - } - } - - /** - * The mandatory [Member]s that the storage always injects into every collection, regardless of - * what the client provides: `fn` (INT64), `version` (INT64), `id` (STRING), `feature` (BYTE_ARRAY). - * - * These are derived from [mandatoryColumns]. Clients must not declare them with a different type. - * @since 3.0 - */ - @JvmField - @JsStatic - val mandatoryMembers: List = mandatoryColumns.map { col -> - Member(col.name, pgTypeToMemberType(col)) - } - - /** - * The mandatory [Member]s for a HISTORY table: [mandatoryMembers] plus `next_version` (INT64). - * @since 3.0 - */ - @JvmField - @JsStatic - val mandatoryHistoryMembers: List = mandatoryHistoryColumns.map { col -> - Member(col.name, pgTypeToMemberType(col)) - } - - /** - * The default [Member]s injected when the client does **not** provide a [members][naksha.model.objects.NakshaCollection.members] - * list (backward-compatible full schema). These correspond to all optional built-in columns - * (i.e. [headColumns] minus [mandatoryColumns]). - * @since 3.0 - */ - @JsStatic - val defaultMembers: List by lazy { - val mandatory = mandatoryColumns.map { it.name }.toSet() - headColumns.filter { it.name !in mandatory }.map { col -> - Member(col.name, pgTypeToMemberType(col)) - } - } - - /** - * Returns the [PgColumn] that matches to the official [MetaColumn]. - * @param metaColumn the [MetaColumn] to resolve. - * @return the [PgColumn] that matches this [MetaColumn]; if any. - * @since 3.0 - */ - @JvmStatic - @JsStatic - fun ofRowColumn(metaColumn: MetaColumn): PgColumn? = when (metaColumn.name) { - MetaColumn.NEXT_VERSION -> next_version - MetaColumn.UPDATED_AT -> updated_at - MetaColumn.CREATED_AT -> created_at - MetaColumn.AUTHOR_TS -> author_ts - MetaColumn.CV0 -> cv0 - MetaColumn.CV1 -> cv1 - MetaColumn.CV2 -> cv2 - MetaColumn.CV3 -> cv3 - MetaColumn.HASH -> hash - MetaColumn.HERE_TILE -> here_tile - MetaColumn.CHANGE_COUNT -> cc - MetaColumn.TUPLE_NUMBER -> fn // tn is split: callers comparing the binary form must decode to (fn, version) - MetaColumn.VERSION -> version - MetaColumn.BASE_TUPLE_NUMBER -> base_tn - MetaColumn.ID -> id - MetaColumn.APP_ID -> app_id - MetaColumn.AUTHOR -> author - MetaColumn.ORIGIN -> origin - MetaColumn.TARGET -> target - MetaColumn.FEATURE_TYPE -> ft - MetaColumn.CS0 -> cs0 - MetaColumn.CS1 -> cs1 - MetaColumn.CS2 -> cs2 - MetaColumn.CS3 -> cs3 - MetaColumn.TAGS -> tags - MetaColumn.REF_POINT -> ref_point - MetaColumn.GEOMETRY -> geo - MetaColumn.FEATURE -> feature - MetaColumn.ATTACHMENT -> attachment - else -> null - } + val NEXT_VERSION = PgColumn(2, NEXT_VERSION_NAME, INT64, "STORAGE $PLAIN NOT NULL") } - @Suppress("NON_EXPORTABLE_TYPE") - override fun namespace(): KClass = PgColumn::class - override fun initClass() {} + /** + * Check if this column is the same as the given one, so [name] and [memberType] match. + * @param other the other column to test against. + * @return _true_ if the two columns are the same; _false_ otherwise. + * @since 3.0 + */ + infix fun eq(other: PgColumn?): Boolean = this === other || (other != null && name == other.name && memberType == other.memberType) + + /** Returns the [ident] of the column, so the quoted [name]. */ + override fun toString(): String = ident } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnRows.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnRows.kt deleted file mode 100644 index 271f303f4e..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnRows.kt +++ /dev/null @@ -1,581 +0,0 @@ -package naksha.psql - -import naksha.base.Int64 -import naksha.base.Platform.PlatformCompanion.toJSON -import naksha.jbon.DictEntry -import naksha.jbon.IBook -import naksha.jbon.JbDictionary -import naksha.model.* -import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B128 -import naksha.model.TupleNumberVariant.TupleNumberVariant_C.B64 -import naksha.model.objects.StandardMembers -import naksha.psql.PgColumn.PgColumnCompanion.allColumns - -/** -* Thin [IBook] wrapper that maps column names to row values from [PgColumnRows]. - * - * This implements [IBook] by delegating index/name lookups to the underlying [rows] - * instance so that [Tuple] getters can resolve members at read time. - * @since 3.0 - */ -internal class PgRowDict( - val row: Int, - private val rows: PgColumnRows, - val fn: Int64, - val dataEncoding: DataEncoding, -) : IBook { - - override val id: String? - get() { - if (fn >= Int64(0)) { - return rows.getString(row, PgColumn.id) - } - return rows.getString(row, PgColumn.id) - } - - override val length: Int - get() = rows.columns.size - - override fun get(index: Int): Any? { - val col = rows.columns.getOrNull(index) ?: return null - return col.values.getOrNull(row) - } - - override fun indexOf(string: String): Int { - val col = rows.columnByName[string] - return col?.index ?: -1 - } - - override fun stringAt(index: Int): String? { - val col = rows.columns.getOrNull(index) ?: return null - val v = col.values.getOrNull(row) - return if (v is String) v else null - } - - override fun hasNames(): Boolean = true - - override fun getIndexOf(name: String): Int = indexOf(name) - - override fun getNameAt(index: Int): String? = rows.columns.getOrNull(index)?.name - - override fun namesLength(): Int = rows.columns.size - - override fun getByName(name: String): Any? { - val col = rows.columnByName[name] ?: return null - return col.values.getOrNull(row) - } - - /** - * Returns the value of a `jsonb` column as raw JSON text. - */ - private fun getJsonText(column: PgColumn): String? { - val value = rows.getAny(row, column) - return when (value) { - null -> null - is String -> value - else -> toJSON(value) - } - } - - override fun find(hash: Int): List = emptyList() -} - -/** - * Helper class to convert rows into arrays of column and vice versa. The main purpose is to read and write full tuples, but it supports basically as well virtual columns. - * @since 3.0 - */ -internal class PgColumnRows { - /** - * All columns being added already. - * @since 3.0 - */ - val columns = mutableListOf() - internal val columnByName = mutableMapOf() - private var isComplete: Boolean? = null - private var names: String? = null - private var namesAggregate: String? = null - private var placeholders: String? = null - private var arrayTypeNames: Array? = null - private fun clearCache(): PgColumnRows { - isComplete = null - names = null - namesAggregate = null - placeholders = null - arrayTypeNames = null - return this - } - - /** - * The amount of rows. - * @since 3.0 - */ - var size: Int = 0 - set(value) { - if (field != value) { - for (column in columns) { - column.values.size = value - } - field = value - } - } - - fun withMinSize(size: Int): PgColumnRows { - if (this.size < size) this.size = size - return this - } - - /** - * If all rows are coming from the same storage, the storage-number of it. - * @since 3.0 - */ - var storageNumber: Int64? = null - - /** - * @see [storageNumber] - */ - fun withStorageNumber(value: Int64): PgColumnRows { - storageNumber = value - return this - } - - /** - * If all rows are coming from the same map, the map-number of it. - * @since 3.0 - */ - var mapNumber: Int? = null - - /** - * @see [mapNumber] - */ - fun withMapNumber(value: Int): PgColumnRows { - mapNumber = value - return this - } - - /** - * If all rows are coming from the same collection, the collection-number of it. - * @since 3.0 - */ - var collectionNumber: Int? = null - - /** - * The feature encoding that applies to every tuple in this result set. - * - * The encoding is no longer persisted as a per-row column; it is a per-collection setting - * looked up by callers (e.g. from [PgCollection.head.dataEncoding]) and passed here so the - * synthesized [Tuple.dataEncoding] tells decoders how to unpack the feature bytes. - * - * **MUST be set via [withDefaultDataEncoding] before any call to [getTuple] / [get]** — - * leaving it unset and then materializing a tuple would silently decode features under the - * wrong encoding, so [getTuple] throws instead of guessing. - * @since 3.0 - */ - var defaultDataEncoding: DataEncoding? = null - - /** - * @see [defaultDataEncoding] - */ - fun withDefaultDataEncoding(value: DataEncoding): PgColumnRows { - defaultDataEncoding = value - return this - } - - /** - * @see [collectionNumber] - */ - fun withCollectionNumber(value: Int): PgColumnRows { - collectionNumber = value - return this - } - - /** - * Tests if tuple read from these rows will be complete. - * @since 3.0 - */ - val complete: Boolean - get() { - val c = isComplete - if (c != null) return c - var detected = true - for (pgColumn in allColumns) { - if (!hasColumn(pgColumn)) { - detected = false - break - } - } - isComplete = detected - return detected - } - - fun addColumn(col: PgColumn): PgColumnRows = addColumn(col.name, col.type) - - fun addColumn(name: String, type: PgType): PgColumnRows { - clearCache() - val existing = columnByName[name] - if (existing == null) { - val column = PgColumnEntry(columns.size, name, type).withSize(size) - columns.add(column) - columnByName[column.name] = column - } - return this - } - - fun addColumns(cols: List): PgColumnRows { - clearCache() - var i = columns.size - for (col in cols) { - val existing = columnByName[col.name] - if (existing == null) { - val column = PgColumnEntry(col, i++).withSize(size) - columns.add(column) - columnByName[column.name] = column - } - } - return this - } - fun getColumn(name: String): PgColumnEntry? = columnByName[name] - fun getColumn(col: PgColumn): PgColumnEntry? = columnByName[col.name] - fun getColumn(index: Int): PgColumnEntry? = if (index in 0 until columns.size) columns[index] else null - fun hasColumn(name: String): Boolean = getColumn(name) != null - fun hasColumn(col: PgColumn): Boolean = getColumn(col) != null - fun hasColumn(index: Int): Boolean = getColumn(index) != null - - - fun getAny(row: Int, columnName: String): Any? = columnByName[columnName]?.values?.get(row) - fun getAny(row: Int, column: PgColumn): Any? = getAny(row, column.name) - fun getInt(row: Int, columnName: String): Int? { - val value = getAny(row, columnName) - return if (value is Int) value else null - } - fun getInt(row: Int, column: PgColumn): Int? = getInt(row, column.name) - fun getInt64(row: Int, columnName: String): Int64? { - val value = getAny(row, columnName) - return if (value is Int64) value else null - } - fun getInt64(row: Int, column: PgColumn): Int64? = getInt64(row, column.name) - fun getDouble(row: Int, columnName: String): Double? { - val value = getAny(row, columnName) - return if (value is Double) value else null - } - fun getDouble(row: Int, column: PgColumn): Double? = getDouble(row, column.name) - fun getString(row: Int, columnName: String): String? { - val value = getAny(row, columnName) - return if (value is String) value else null - } - fun getString(row: Int, column: PgColumn): String? = getString(row, column.name) - fun getByteArray(row: Int, columnName: String): ByteArray? { - val value = getAny(row, columnName) - return if (value is ByteArray) value else null - } - fun getByteArray(row: Int, column: PgColumn): ByteArray? = getByteArray(row, column.name) - fun getB64(row: Int, column: PgColumn, featureNumber: Int64): TupleNumber? = getB64(row, column.name, featureNumber) - fun getB64(row: Int, columnName: String, featureNumber: Int64): TupleNumber? { - val raw = getByteArray(row, columnName) ?: return null - val storageNumber = this.storageNumber ?: return null - val mapNumber = this.mapNumber ?: return null - val collectionNumber = this.collectionNumber ?: return null - return try { - TupleNumber.fromB64(raw, storageNumber, mapNumber, collectionNumber, featureNumber) - } catch (_: Exception) { - null - } - } - fun getB128(row: Int, column: PgColumn): TupleNumber? = getB128(row, column.name) - fun getB128(row: Int, columnName: String): TupleNumber? { - val raw = getByteArray(row, columnName) ?: return null - val storageNumber = this.storageNumber ?: return null - val mapNumber = this.mapNumber ?: return null - val collectionNumber = this.collectionNumber ?: return null - return try { - TupleNumber.fromB128(raw, storageNumber, mapNumber, collectionNumber) - } catch (_: Exception) { - null - } - } - fun getTuple(row: Int, storageNumber: Int64, mapNumber: Int, collectionNumber: Int): Tuple? { - if (row < 0 || row >= size) return null - val fn = getInt64(row, PgColumn.fn) ?: return null - val version = getInt64(row, PgColumn.version) ?: return null - val nextVersion = getInt64(row, PgColumn.next_version) - val encoding = defaultDataEncoding ?: throw illegalState( - "PgColumnRows.defaultDataEncoding was not set before calling getTuple(); " + - "set it from the collection's dataEncoding via withDefaultDataEncoding(...)." - ) - val members = PgRowDict( - row = row, - rows = this, - fn = fn, - dataEncoding = encoding - ) - return Tuple( - storageNumber = storageNumber, - mapNumber = mapNumber, - collectionNumber = collectionNumber, - featureNumber = fn, - version = naksha.model.Version(version), - nextVersion = nextVersion ?: Int64(-1L), - members = members, - feature = getByteArray(row, PgColumn.feature) - ) - } - - /** - * Returns the value of a `jsonb` column as raw JSON text. - * - * `jsonb` columns come back from the cursor pre-parsed (a [naksha.base.PlatformMap] / - * [naksha.base.PlatformList]) since most callers want a typed object. For carriers that hold - * the raw JSON text (like tags) we round-trip through [toJSON] to recover it. - */ - private fun getJsonText(row: Int, column: PgColumn): String? { - val value = getAny(row, column) - return when (value) { - null -> null - is String -> value - else -> toJSON(value) - } - } - - operator fun get(row: Int): Tuple? { - val storage_num = storageNumber ?: return null - val map_num = mapNumber ?: return null - val col_num = collectionNumber ?: return null - return getTuple(row, storage_num, map_num, col_num) - } - - fun set(row: Int, columnName: String, value: Any?): Boolean { - val column = getColumn(columnName) - if (column != null) { - withMinSize(row) - column.values[row] = value - return true - } - return false - } - fun set(row: Int, column: PgColumn, value: Any?): Boolean = set(row, column.name, value) - /** - * Adds one [PgColumnEntry] per declared [naksha.model.objects.Member]. - * - * Idempotent — built-in names are never re-added by addColumns(allColumns), and members are checked individually. - */ - fun addCustomMembers(members: naksha.model.objects.MemberList?): PgColumnRows { - if (members == null) return this - for (m in members) { - if (m == null) continue - addColumn(PgCustomMemberValues.pgColumnName(m.name), PgCustomMemberValues.pgTypeFor(m.dataType)) - } - return this - } - - /** - * Populates the [Member][naksha.model.objects.Member] columns for the given row by walking the [feature] using each member's [path][naksha.model.objects.Member.effectivePath] and coercing the value to the SQL type. - * - * Missing keys and mismatched types both produce a NULL column value. Mismatches additionally emit a warning via [naksha.base.Platform.PlatformCompanion.logger]. - */ - fun setCustomMembers(row: Int, feature: naksha.model.objects.NakshaFeature?, members: naksha.model.objects.MemberList?) { - if (feature == null || members == null) return - for (m in members) { - if (m == null) continue - val raw = PgCustomMemberValues.walkFeature(feature, m.effectivePath()) - val coerced = PgCustomMemberValues.coerce(raw, m.dataType, feature.id, m.name) - set(row, PgCustomMemberValues.pgColumnName(m.name), coerced) - } - } - - operator fun set(row: Int, tuple: Tuple) { - withMinSize(row) - val members = tuple.members ?: return - set(row, PgColumn.updated_at, members.getByName("updated_at") as? Int64) - set(row, PgColumn.created_at, members.getByName("created_at") as? Int64) - set(row, PgColumn.author_ts, members.getByName("author_ts") as? Int64) - set(row, PgColumn.cv0, members.getByName("cv0") as? Double) - set(row, PgColumn.cv1, members.getByName("cv1") as? Double) - set(row, PgColumn.cv2, members.getByName("cv2") as? Double) - set(row, PgColumn.cv3, members.getByName("cv3") as? Double) - set(row, PgColumn.hash, members.getByName("hash") as? Int) - set(row, PgColumn.here_tile, members.getByName("here_tile") as? Int) - set(row, PgColumn.cc, members.getByName("cc") as? Int) - val fn = tuple.featureNumber - set(row, PgColumn.fn, fn) - set(row, PgColumn.version, tuple.version.txn) - set(row, PgColumn.next_version, tuple.nextVersion) - set(row, PgColumn.base_tn, members.getByName("base_tn") as? ByteArray) - set(row, PgColumn.id, if (fn >= Int64(0)) null else members.getByName("id") as? String) - set(row, PgColumn.app_id, members.getByName("app_id") as? String) - set(row, PgColumn.author, members.getByName("author") as? String) - set(row, PgColumn.origin, members.getByName("origin") as? String) - set(row, PgColumn.target, members.getByName("target") as? String) - set(row, PgColumn.ft, members.getByName("ft") as? String) - set(row, PgColumn.cs0, members.getByName("cs0") as? String) - set(row, PgColumn.cs1, members.getByName("cs1") as? String) - set(row, PgColumn.cs2, members.getByName("cs2") as? String) - set(row, PgColumn.cs3, members.getByName("cs3") as? String) - set(row, PgColumn.tags, members.getByName("tags") as? String) - set(row, PgColumn.ref_point, members.getByName("ref_point") as? ByteArray) - set(row, PgColumn.feature, tuple.feature) - set(row, PgColumn.geo, members.getByName("geo") as? ByteArray) - set(row, PgColumn.attachment, members.getByName("attachment") as? ByteArray) - } - - operator fun set(row: Int, cursor: PgCursor) { - withMinSize(row) - for (column in columns) { - if (cursor.contains(column.name)) { - val value = cursor.column(column.name) - column.values[row] = value - } - } - } - - /** - * Add the current row of the cursor. - * @param cursor the cursor from which to read. - * @return `true` if a rows was read; `false` if the cursor is not at a valid row. - * @since 3.0 - */ - fun add(cursor: PgCursor): Boolean { - if (cursor.isRow()) { - val row = size - size += 1 - for (column in columns) { - if (cursor.contains(column.name)) { - val value = cursor.column(column.name) - column.values[row] = value - } - } - return true - } - return false - } - - /** - * Read all rows from cursor, expects the cursor to be at first result, usage: - * ```kotlin - * plan.execute(queryValues).fetch().use { resultRows.addAll(it) } - * ``` - * @since 3.0 - */ - fun addAll(cursor: PgCursor): PgColumnRows { - while (add(cursor)) cursor.next() - return this - } - - /** - * Read all rows from cursor, expects the cursor to be at first result and that for each column, there is an array of values, so an aggregate generated via `ARRAY_AGG`. - * @since 3.0 - */ - fun addAggregated(cursor: PgCursor): PgColumnRows { - if (cursor.isRow()) { - for (column in columns) { - if (cursor.contains(column.name)) { - val values = cursor.column(column.name) - if (values is Array<*>) { - withMinSize(values.size) - for (i in 0 until values.size) { - set(i, column.name, values[i]) - } - } - } - } - } - return this - } - - /** - * Returns the names of all columns as comma separated string. - * @return the names of all columns as comma separated string. - * @since 3.0 - */ - fun names(): String { - val cached = this.names - if (cached != null) return cached - val names = columns.joinToString(", ") { PgUtil.quoteIdent(it.name) } - this.names = names - return names - } - - /** - * Returns the names of all columns as comma separated string, surrounded with aggregation instruction, _(like `ARRAY_AGG(id)`)_, usage: - * - * ```kotlin - * val rows = PgColumnRows().addColumns(allColumns) - * val SQL = """SELECT ${rows.namesAggregate()} - * FROM "naksha~admin".${collections.head.quotedName} - * WHERE id = ANY($1)""" - * val plan = conn.prepare(SQL, rows.typeNames()) - * val cursor = plan.execute(rows.valuesExecutable()) - * ``` - * - * @return the names of all columns as comma separated string, surrounded with aggregation instruction. - * @since 3.0 - */ - fun namesAggregate(): String { - val cached = this.namesAggregate - if (cached != null) return cached - val names = columns.joinToString(", ") { - val q = PgUtil.quoteIdent(it.name) - "ARRAY_AGG($q) AS $q" - } - this.namesAggregate = names - return names - } - - /** - * Returns the placeholders of all columns as comma separated string _($1, $2, ...)_, usage: - * - * ```sql - * WITH new_row AS ( - * SELECT * FROM UNNEST(${rows.placeholders()}) - * AS t(${rows.names()}) - * ) - * INSERT INTO ${collection.head.quotedName} (${rows.names()}) - * SELECT * FROM new_row - * ``` - * - * @return the placeholders of all columns as comma separated string. - * @since 3.0 - */ - fun placeholders(): String { - val cached = this.placeholders - if (cached != null) return cached - val placeholders = columns.joinToString(", ") { "\$${(it.index + 1)}" } - this.placeholders = placeholders - return placeholders - } - - /** - * Returns the array type-names of all columns, for example, when the type is [PgType.INT64], it will return `int8[]`, usage: - * - * ```kotlin - * val sql = """WITH new_row AS ( - * SELECT * FROM UNNEST(${rows.placeholders()}) - * AS t(${rows.names()}) - * ) - * INSERT INTO ${collection.head.quotedName} (${rows.names()}) - * SELECT * FROM new_row""" - * val plan = conn.prepare(sql, rows.typeNames()) - * ``` - * @return the array type-names of all columns. - * @since 3.0 - */ - fun typeNames(): Array = Array(columns.size) { columns[it].type.text + "[]" } - - /** - * Returns the values of all columns cast to a type that is supported by [PgPlan.execute], usage: - * - * ```kotlin - * val sql = """WITH new_row AS ( - * SELECT * FROM UNNEST(${rows.placeholders()}) - * AS t(${rows.names()}) - * ) - * INSERT INTO ${collection.head.quotedName} (${rows.names()}) - * SELECT * FROM new_row""" - * val plan = conn.prepare(sql, rows.typeNames()) - * val cursor = plan.execute(rows.valuesExecutable()) - * ``` - * Beware that the array really is two-dimensional: `Array>`. - * @return the values of all columns as `Array`. - * @since 3.0 - */ - @Suppress("UNCHECKED_CAST") - fun values(): Array = Array(columns.size) { columns[it].anyArray() } as Array -} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnEntry.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnWithValues.kt similarity index 64% rename from here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnEntry.kt rename to here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnWithValues.kt index b2520c3f5d..6cd6d68482 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnEntry.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgColumnWithValues.kt @@ -3,20 +3,36 @@ package naksha.psql import naksha.base.AnyList import naksha.base.Int64 +/** + * Rows as selected in [PgRows]. + * @since 3.0 + */ @Suppress("UNCHECKED_CAST") -internal data class PgColumnEntry( - val index: Int, - val name: String, - val type: PgType, +internal data class PgColumnWithValues( + /** + * The database column. + * @since 3.0 + */ + val pgColumn: PgColumn, + + /** + * An optional alias, if the column is mapped to a different name in the result-set. + * @since 3.0 + */ + val alias: String = pgColumn.name, + + /** + * The values of the column for each row. + * @since 3.0 + */ val values: AnyList = AnyList() ) { - constructor(column: PgColumn, index: Int = column.i) : this(index, column.name, column.type) - - fun withSize(size: Int): PgColumnEntry { + fun withSize(size: Int): PgColumnWithValues { values.size = size return this } fun anyValues(): MutableList = values + /** Returns all values of this column as array. */ fun anyArray(): Array = values.toArray() fun intValues(): MutableList = values as MutableList fun intArray(): Array = values.toArray() as Array @@ -28,4 +44,6 @@ internal data class PgColumnEntry( fun stringArray(): Array = values.toArray() as Array fun byteArrayValues(): MutableList = values as MutableList fun byteArrayArray(): Array = values.toArray() as Array + + override fun toString(): String = alias } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCursor.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCursor.kt index f086d3e400..44f18c5e6d 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCursor.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCursor.kt @@ -44,6 +44,12 @@ interface PgCursor : AutoCloseable { */ fun rowNumber(): Int + /** + * Returns the names of the selected columns. + * @return the names of the selected columns. + */ + fun columnNames(): Array + /** * Tests if the current row has the given colum. * @param name the name of the column diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCustomMemberValues.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCustomMemberValues.kt deleted file mode 100644 index 2d9be1de98..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgCustomMemberValues.kt +++ /dev/null @@ -1,342 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.psql - -import naksha.base.AnyList -import naksha.base.AnyObject -import naksha.base.Int64 -import naksha.base.ListProxy -import naksha.base.PlatformList -import naksha.base.Platform.PlatformCompanion.logger -import naksha.base.Platform.PlatformCompanion.toJSON -import naksha.model.NakshaError -import naksha.model.NakshaException -import naksha.model.TagList -import naksha.model.objects.Member -import naksha.model.objects.MemberList -import naksha.model.objects.MemberType -import naksha.model.objects.NakshaFeature - -/** - * Helpers to map [CustomMember] values from a [NakshaFeature] into a [PgColumnRows] row. - * - * - [walkFeature]: descend a [NakshaFeature] using the member's path; returns _null_ if the path is missing. - * - [coerce]: coerce a raw value to the type of the member; returns _null_ and logs a warning on mismatch. - * - [pgTypeFor]: maps a [CustomMemberType] to the [PgType] used for prepared-statement binding. - * - [pgSqlTypeFor]: returns the PostgreSQL DDL type for `CREATE TABLE` / `ALTER TABLE ADD COLUMN`. - * - [pgColumnName]: returns the physical column name (same as [CustomMember.name]) used in Postgres. - */ -internal object PgCustomMemberValues { - - /** - * The set of all reserved column names — any name that belongs to a built-in [PgColumn]. - * Custom members must not use any of these names; [validateMemberNames] enforces this. - */ - private val reservedColumnNames: Set by lazy { - PgColumn.allColumns.map { it.name }.toSet() - } - - /** - * Returns the physical Postgres column name for the given member name. - * The name is used as-is; collision with built-in columns is prevented by [validateMemberNames]. - */ - fun pgColumnName(memberName: String): String = memberName - - /** - * Validates that none of the members in [members] use a reserved built-in column name. - * Throws [NakshaException] with [NakshaError.ILLEGAL_ARGUMENT] on the first conflict found. - * Must be called before creating a new collection. - */ - fun validateMemberNames(members: MemberList) { - for (member in members) { - if (member != null && member.name in reservedColumnNames) { - throw NakshaException( - NakshaError.ILLEGAL_ARGUMENT, - "Custom member name '${member.name}' conflicts with a built-in column name" - ) - } - } - } - - fun pgTypeFor(type: MemberType): PgType = when (type) { - MemberType.BOOLEAN -> PgType.BOOLEAN - MemberType.INT8 -> PgType.SHORT - MemberType.INT16 -> PgType.SHORT - MemberType.INT32 -> PgType.INT - MemberType.INT64 -> PgType.INT64 - MemberType.FLOAT32 -> PgType.FLOAT - MemberType.FLOAT64 -> PgType.DOUBLE - MemberType.STRING -> PgType.STRING - MemberType.BYTE_ARRAY -> PgType.BYTE_ARRAY - MemberType.SPATIAL -> PgType.BYTE_ARRAY - MemberType.TAGS -> PgType.JSONB - MemberType.TAGS_FROM_ARRAY -> PgType.JSONB - MemberType.SET -> PgType.JSONB - else -> PgType.STRING - } - - /** - * Returns the PostgreSQL DDL type string for the given member type, used inside `CREATE TABLE` / `ALTER TABLE ADD COLUMN`. - * Note: there is no 1-byte signed integer type in PostgreSQL, so [MemberType.INT8] is materialized as `smallint`; - * the storage enforces the 8-bit range on coercion. - * [MemberType.TAGS], [MemberType.TAGS_FROM_ARRAY], and [MemberType.SET] all use `jsonb STORAGE MAIN` — - * compressed inline, only TOASTed as a last resort. The only difference is the JSON shape: - * TAGS and TAGS_FROM_ARRAY persist a JSON object, SET persists a JSON array. - */ - fun pgSqlTypeFor(type: MemberType): String = when (type) { - MemberType.BOOLEAN -> "boolean" - MemberType.INT8 -> "smallint" - MemberType.INT16 -> "smallint" - MemberType.INT32 -> "integer" - MemberType.INT64 -> "bigint" - MemberType.FLOAT32 -> "real" - MemberType.FLOAT64 -> "double precision" - MemberType.STRING -> "text COLLATE \"C\"" - MemberType.BYTE_ARRAY -> "bytea" - MemberType.SPATIAL -> "bytea STORAGE EXTERNAL" - MemberType.TAGS -> "jsonb STORAGE MAIN" - MemberType.TAGS_FROM_ARRAY -> "jsonb STORAGE MAIN" - MemberType.SET -> "jsonb STORAGE MAIN" - else -> "text" - } - - /** - * Returns the SQL fragment for the [Member.name] / [Member.dataType] used in `CREATE TABLE`. Example: `"age" smallint`. - */ - fun sqlDefinitionFor(member: Member): String = - "\"${pgColumnName(member.name)}\" ${pgSqlTypeFor(member.dataType)}" - - fun walkFeature(feature: NakshaFeature, path: List): Any? { - var current: Any? = feature - for (segment in path) { - if (current == null) return null - current = when (current) { - is AnyObject -> current.getRaw(segment) - else -> return null - } - } - return current - } - - fun coerce(value: Any?, type: MemberType, featureId: String, memberName: String): Any? { - if (value == null) return null - return when (type) { - MemberType.BOOLEAN -> coerceBoolean(value, featureId, memberName) - MemberType.INT8 -> coerceInt8(value, featureId, memberName) - MemberType.INT16 -> coerceInt16(value, featureId, memberName) - MemberType.INT32 -> coerceInt32(value, featureId, memberName) - MemberType.INT64 -> coerceInt64(value, featureId, memberName) - MemberType.FLOAT32 -> coerceFloat32(value, featureId, memberName) - MemberType.FLOAT64 -> coerceFloat64(value, featureId, memberName) - MemberType.STRING -> coerceString(value, featureId, memberName) - MemberType.BYTE_ARRAY -> coerceByteArray(value, featureId, memberName) - MemberType.SPATIAL -> coerceByteArray(value, featureId, memberName) - MemberType.TAGS -> coerceTags(value, featureId, memberName) - MemberType.TAGS_FROM_ARRAY -> coerceTagsFromArray(value, featureId, memberName) - MemberType.SET -> coerceSet(value, featureId, memberName) - else -> { - warnMismatch(featureId, memberName, type.toString(), value) - null - } - } - } - - private fun coerceBoolean(value: Any, featureId: String, memberName: String): Boolean? = when (value) { - is Boolean -> value - else -> { warnMismatch(featureId, memberName, "boolean", value); null } - } - - private fun coerceInt8(value: Any, featureId: String, memberName: String): Short? { - val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int8", value) } - if (asLong !in Byte.MIN_VALUE.toLong()..Byte.MAX_VALUE.toLong()) { - warnMismatch(featureId, memberName, "int8 (out of range)", value) - return null - } - return asLong.toShort() - } - - private fun coerceInt16(value: Any, featureId: String, memberName: String): Short? { - val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int16", value) } - if (asLong !in Short.MIN_VALUE.toLong()..Short.MAX_VALUE.toLong()) { - warnMismatch(featureId, memberName, "int16 (out of range)", value) - return null - } - return asLong.toShort() - } - - private fun coerceInt32(value: Any, featureId: String, memberName: String): Int? { - val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int32", value) } - if (asLong !in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { - warnMismatch(featureId, memberName, "int32 (out of range)", value) - return null - } - return asLong.toInt() - } - - private fun coerceInt64(value: Any, featureId: String, memberName: String): Int64? = when (value) { - is Int64 -> value - is Int -> Int64(value.toLong()) - is Long -> Int64(value) - is Short -> Int64(value.toLong()) - is Byte -> Int64(value.toLong()) - is Double -> if (value.isFinite() && value == value.toLong().toDouble()) Int64(value.toLong()) else { warnMismatch(featureId, memberName, "int64", value); null } - is Float -> if (value.isFinite() && value == value.toLong().toFloat()) Int64(value.toLong()) else { warnMismatch(featureId, memberName, "int64", value); null } - else -> { warnMismatch(featureId, memberName, "int64", value); null } - } - - private fun coerceFloat32(value: Any, featureId: String, memberName: String): Float? = when (value) { - is Float -> value - is Double -> value.toFloat() - is Int -> value.toFloat() - is Long -> value.toFloat() - is Int64 -> value.toLong().toFloat() - is Short -> value.toFloat() - is Byte -> value.toFloat() - else -> { warnMismatch(featureId, memberName, "float32", value); null } - } - - private fun coerceFloat64(value: Any, featureId: String, memberName: String): Double? = when (value) { - is Double -> value - is Float -> value.toDouble() - is Int -> value.toDouble() - is Long -> value.toDouble() - is Int64 -> value.toLong().toDouble() - is Short -> value.toDouble() - is Byte -> value.toDouble() - else -> { warnMismatch(featureId, memberName, "float64", value); null } - } - - private fun coerceString(value: Any, featureId: String, memberName: String): String? = when (value) { - is String -> value - else -> { warnMismatch(featureId, memberName, "string", value); null } - } - - private fun coerceByteArray(value: Any, featureId: String, memberName: String): ByteArray? = when (value) { - is ByteArray -> value - else -> { warnMismatch(featureId, memberName, "byte_array", value); null } - } - - private fun coerceTags(value: Any, featureId: String, memberName: String): String? { - if (value !is AnyObject) { - warnMismatch(featureId, memberName, "tags", value) - return null - } - return try { toJSON(value) } catch (_: Exception) { warnMismatch(featureId, memberName, "tags", value); null } - } - - private fun coerceTagsFromArray(value: Any, featureId: String, memberName: String): String? { - val tagList = when (value) { - is TagList -> value - is AnyObject -> value.proxy(TagList::class) - else -> { warnMismatch(featureId, memberName, "tags_from_array", value); return null } - } - val tagMap = tagList.toTagMap() - return try { toJSON(tagMap) } catch (_: Exception) { warnMismatch(featureId, memberName, "tags_from_array", value); null } - } - - /** - * Coerces a [MemberType.SET] value: a JSON array of unique primitives (booleans, numbers, strings). - * The array is persisted unmodified (element order preserved). Entries that are `null`, non-primitive, - * or duplicates violate the set contract; the value is then not materialized (warning + NULL column). - */ - private fun coerceSet(value: Any, featureId: String, memberName: String): String? { - val list: ListProxy<*> = when (value) { - is ListProxy<*> -> value - is PlatformList -> value.proxy(AnyList::class) - else -> { warnMismatch(featureId, memberName, "set", value); return null } - } - val seen = HashSet() - for (e in list) { - if (e == null) { - warnMismatch(featureId, memberName, "set (entries must not be null)", "null") - return null - } - if (!isPrimitive(e)) { - warnMismatch(featureId, memberName, "set (entries must be primitives)", e) - return null - } - if (!seen.add(e)) { - warnMismatch(featureId, memberName, "set (entries must be unique)", e) - return null - } - } - return try { toJSON(list) } catch (_: Exception) { warnMismatch(featureId, memberName, "set", list); null } - } - - private fun isPrimitive(value: Any): Boolean = when (value) { - is String, is Boolean, is Byte, is Short, is Int, is Long, is Int64, is Float, is Double -> true - else -> false - } - - private fun numberToLongOrNull(value: Any): Long? = when (value) { - is Byte -> value.toLong() - is Short -> value.toLong() - is Int -> value.toLong() - is Long -> value - is Int64 -> value.toLong() - is Float -> if (value.isFinite() && value == value.toLong().toFloat()) value.toLong() else null - is Double -> if (value.isFinite() && value == value.toLong().toDouble()) value.toLong() else null - else -> null - } - - /** - * Returns the sort priority for a [MemberType] based on PostgreSQL alignment size, to minimise - * tuple padding when columns are laid out in declaration order: - * 1. 8-byte types ([INT64], [FLOAT64]) — first, to get 8-byte alignment right away - * 2. 4-byte types ([INT32], [FLOAT32]) — next, still fixed-width - * 3. 1/2-byte types ([INT16], [INT8], [BOOLEAN]) — small fixed-width - * 4. Variable-length text ([STRING]) — variable but human-readable - * 5. Opaque variable-length ([BYTE_ARRAY], [SPATIAL], [TAGS], [TAGS_FROM_ARRAY], [SET]) — last - */ - fun columnSortOrder(type: MemberType): Int = when (type) { - MemberType.INT64 -> 0 - MemberType.FLOAT64 -> 1 - MemberType.INT32 -> 2 - MemberType.FLOAT32 -> 3 - MemberType.INT16 -> 4 - MemberType.INT8 -> 5 - MemberType.BOOLEAN -> 6 - MemberType.STRING -> 7 - MemberType.BYTE_ARRAY -> 8 - MemberType.SPATIAL -> 8 - MemberType.TAGS -> 9 - MemberType.TAGS_FROM_ARRAY -> 10 - MemberType.SET -> 11 - else -> 12 - } - - /** - * The set of member names that correspond to pre-defined optional [PgColumn]s (e.g. `geo`, `tags`, `cc`). - * When two members have the same type sort-order, pre-defined members are placed before user-invented ones. - */ - private val predefinedMemberNames: Set by lazy { - val mandatory = PgColumn.mandatoryColumns.map { it.name }.toSet() - PgColumn.headColumns.map { it.name }.filter { it !in mandatory }.toSet() - } - - /** - * Sorts [members] in-place for optimal PostgreSQL column layout. - * - * Ordering rules (applied only at **collection-creation** time; never on updates): - * 1. Primary: type alignment group ([columnSortOrder]) - * 2. Secondary: pre-defined members (matching a built-in optional column name) before user-invented ones - * 3. Tertiary: member name lexicographically ascending - * - * The sort is stable within each group so that the caller's intent is preserved as a tie-breaker. - */ - fun sortMembersForStorage(members: MemberList) { - if (members.size <= 1) return - val snapshot = (0 until members.size).map { members[it]!! } - val sorted = snapshot.sortedWith(compareBy( - { columnSortOrder(it.dataType) }, - { if (it.name in predefinedMemberNames) 0 else 1 }, - { it.name } - )) - members.clear() - members.addAll(sorted) - } - - private fun warnMismatch(featureId: String, memberName: String, expected: String, value: Any) { - logger.warn("Custom member '$memberName' on feature '$featureId': expected $expected, got ${value::class.simpleName}") - } -} diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgDistributionPartition.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgDistributionPartition.kt new file mode 100644 index 0000000000..2cf90a6fcd --- /dev/null +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgDistributionPartition.kt @@ -0,0 +1,75 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.psql + +import naksha.model.NakshaError.NakshaErrorCompanion.INTERNAL_ERROR +import naksha.model.NakshaException +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.VERSION +import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent +import kotlin.js.JsExport +import kotlin.js.JsName +import kotlin.jvm.JvmField + +/** + * A distribution partition of either [PgHeadTable] or [PgHistoryPartition], used to store a huge amount of features in a collection. + * + * The name of the partitions will be `{name}${featureNumber % partitions.size}`, e.g. `foo$0` or `foo$hst$2026$0`. + * @since 3.0 + * @see [PgHeadTable] + * @see [PgHistoryPartition] + */ +@JsExport +class PgDistributionPartition private constructor( + /** + * The parent table, either [PgHeadTable] or [PgHistoryPartition]. + * @since 3.0 + */ + parent: PgTable, + + /** + * The partition-number of this partition in the parent partitions, a value between `0` and `n`, with `n` being `parent.partitions.size - 1`. + * @since 3.0 + */ + @JvmField + val partitionIndex: Int +) : PgTable(parent.collection, parent.name + '$' + partitionIndex, parent) { + /** + * Create a distribution partition in the [PgHeadTable]. + */ + @JsName("newHeadDistributionPartition") + constructor(parent: PgHeadTable, partitionNumber: Int) : this(parent as PgTable, partitionNumber) + + /** + * Create a distribution partition in the history aka [PgHistoryPartition]. + */ + @JsName("newHistoryDistributionPartition") + constructor(parent: PgHistoryPartition, partitionNumber: Int) : this(parent as PgTable, partitionNumber) + + override fun CREATE_SQL(): String { + val (CREATE_TABLE, TABLESPACE) = CREATE_TABLE_and_TABLESPACE() + val ID = collection.column(Id) + val NEXT_VERSION = collection.column(NextVersion) + + // partition of HEAD. + if (parent is PgHeadTable) return """$CREATE_TABLE $quotedName +PARTITION OF ${parent.quotedName} (${parent.CONSTRAINT(partitionIndex)}) +FOR VALUES FROM ($partitionIndex) TO (${partitionIndex+1}) +WITH (fillfactor=50,toast_tuple_target=$toast_tuple_target)$TABLESPACE; +CREATE INDEX ${quoteIdent(name, "\$i_version")} ON $quotedName USING btree ($VERSION) INCLUDE ($FN, $ID);""" + + // partition of HISTORY-PARTITION. + if (parent is PgHistoryPartition) { + val root = parent.parent as PgHistoryTable + return """$CREATE_TABLE $quotedName +PARTITION OF ${parent.quotedName} (${root.CONSTRAINT(parent.partitionIndex, partitionIndex)}) +FOR VALUES FROM ($partitionIndex) TO (${partitionIndex+1}) +WITH (fillfactor=100,toast_tuple_target=$toast_tuple_target)$TABLESPACE; +CREATE INDEX ${quoteIdent(name, "\$i_version")} ON $quotedName USING btree ($VERSION, $NEXT_VERSION) INCLUDE ($FN, $ID);""" + } + + throw NakshaException(INTERNAL_ERROR, "The distribution partition must have PgHeadTable or PgHistoryPartition as parent") + } +} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHead.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHead.kt deleted file mode 100644 index d00d8a112f..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHead.kt +++ /dev/null @@ -1,105 +0,0 @@ -package naksha.psql - -import naksha.model.NakshaError.NakshaErrorCompanion.PARTITION_NOT_FOUND -import naksha.model.NakshaException -import naksha.psql.PgUtil.PgUtilCompanion.partitionNumber -import kotlin.js.JsExport -import kotlin.js.JsName -import kotlin.jvm.JvmField - -/** - * The HEAD table of a collection. - * @since 3.0 - * @see [PgTable] - * @see [PgHeadPartition] - */ -@Suppress("OPT_IN_USAGE") -@JsExport -open class PgHead protected constructor( - collection: PgCollection, - name: String, - storageClass: PgStorageClass, - isVolatile: Boolean, - partitionOf: PgTable? = null, - partitionOfValue: Int = -1, - partitionBy: PgColumn? = null, - partitionCount: Int = 0 -) : PgTable( - collection, name, storageClass, isVolatile, - partitionOf, partitionOfValue, partitionBy, partitionCount -) { - /** - * Create a new ordinary HEAD table. - * @param c the collection for which to create the HEAD table. - * @param storageClass the storage-class for this table and all related. - * @param partCount the amount of partitions to create (0 or 2 to 256). - */ - @JsName("of") - constructor(c: PgCollection, storageClass: PgStorageClass, partCount: Int) : this( - c, "${c.id}${PG_HEAD}", storageClass, true, - partitionCount = if (partCount <= 1) 0 else partCount, - partitionBy = if (partCount >= 2) PgColumn.fn else null - ) - - /** - * All performance partitions to stored into; empty if no performance partitioning. - */ - @JvmField - val partitions: Array = if (partitionCount <= 1) emptyArray() else Array(partitionCount) { - PgHeadPartition(this, it) - } - - /** - * Calculate the performance partition into which to write the feature with the given ID. - * @param featureId the ID of the feature to locate the performance partition for. - * @return either the performance partition to put the feature into; _null_ if the table is not partitioned, features need to be written into the table itself. - */ - operator fun get(featureId: String): PgHeadPartition? { - val partitions = this.partitions - if (partitions.size == 0) return null - val i = partitionNumber(featureId) % partitions.size - check(i >= partitions.size) { throw NakshaException(PARTITION_NOT_FOUND, "Partition $i not found in table $name") } - return partitions[i] - } - - override fun create(conn: PgConnection) { - super.create(conn) - for (partition in partitions) partition.create(conn) - } - - override fun createIndex(conn: PgConnection, index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.createIndex(conn, index) - } else { - super.createIndex(conn, index) - } - if (index !in indices) indices += index - } - - override fun addIndex(index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.addIndex(index) - } else { - super.addIndex(index) - } - if (index !in indices) indices += index - } - - override fun removeIndex(index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.removeIndex(index) - } else { - super.removeIndex(index) - } - if (index in indices) indices -= index - } - - override fun dropIndex(conn: PgConnection, index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.dropIndex(conn, index) - } else { - super.dropIndex(conn, index) - } - if (index in indices) indices -= index - } -} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHeadPartition.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHeadPartition.kt deleted file mode 100644 index d7eae6929a..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHeadPartition.kt +++ /dev/null @@ -1,18 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.psql - -import kotlin.js.JsExport - -/** - * A feature partition for performance optimisation. - * @property head the head table. - * @param index the index in [PgHead.partitions]. - * @since 3.0 - * @see [PgHead] - */ -@JsExport -class PgHeadPartition internal constructor(val head: PgHead, index: Int) : PgTable( - head.collection, "${head.name}${PG_PART}${PgUtil.partitionSuffix(index)}", head.storageClass, true, - partitionOfTable = head, partitionOfValue = index -) \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHeadTable.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHeadTable.kt new file mode 100644 index 0000000000..f47c56943b --- /dev/null +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHeadTable.kt @@ -0,0 +1,119 @@ +package naksha.psql + +import naksha.base.Int64 +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.NEXT_VERSION +import naksha.psql.PgColumn.PgColumn_C.VERSION +import naksha.psql.PgUtil.PgUtilCompanion.partitionNumber +import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent +import kotlin.js.JsExport +import kotlin.js.JsName +import kotlin.jvm.JvmField + +/** + * The _HEAD_ table of a [collection][naksha.model.objects.NakshaCollection], storing the latest [tuple][naksha.model.Tuple] of the [features][naksha.model.objects.NakshaFeature] being part of the [collection][naksha.model.objects.NakshaCollection]. + * @since 3.0 + */ +@Suppress("OPT_IN_USAGE") +@JsExport +class PgHeadTable( + /** The collection to which this HEAD table belongs. */ + collection: PgCollection, +) : PgTable(collection, collection.id, null) { + + /** + * All distribution partitions; if not partitioned, then an empty array. + * @since 3.0 + */ + @JvmField + val partitions: Array = if (collection.partitions <= 1) emptyArray() else Array(collection.partitions) { + PgDistributionPartition(this, it) + } + + @Suppress("FunctionName") + internal fun CONSTRAINT(): String { + val ID = collection.column(Id) + return """ + CONSTRAINT ${quoteIdent(name, "\$c_pkey")} PRIMARY KEY ($FN) INCLUDE ($VERSION, $ID), + CONSTRAINT ${quoteIdent(name, "\$c_fn")} CHECK (($FN < 0 AND $ID IS NOT NULL) OR ($FN >= 0 AND $ID IS NULL)), + CONSTRAINT ${quoteIdent(name, "\$c_nv")} CHECK ($NEXT_VERSION IS NULL), + CONSTRAINT ${quoteIdent(name, "\$c_id")} UNIQUE ($ID) INCLUDE ($VERSION, $FN})""" + } + + @Suppress("FunctionName") + internal fun CONSTRAINT(distributionPartition: Int): String { + return """${CONSTRAINT()}, + CONSTRAINT ${quoteIdent(name, "\$c_fnr")} CHECK ((($FN & 65535)::int4 % ${collection.partitions})=$distributionPartition) + """ + } + + override fun CREATE_SQL(): String { + val (CREATE_TABLE, TABLESPACE) = CREATE_TABLE_and_TABLESPACE() + val ID = collection.column(Id) + + // HEAD is NOT distribution partitioned. + if (partitions.isEmpty()) return """$CREATE_TABLE $quotedName (${columnDefinitions()}, ${CONSTRAINT()}) +WITH (fillfactor=50,toast_tuple_target=$toast_tuple_target)$TABLESPACE; +CREATE INDEX ${quoteIdent(name, "\$i_version")} ON $quotedName USING btree ($VERSION) INCLUDE ($FN, $ID);""" + + // HEAD is distribution partitioned. + return """$CREATE_TABLE $quotedName (${columnDefinitions()}) +WITH (fillfactor=50,toast_tuple_target=$toast_tuple_target) +PARTITION BY RANGE ((($FN & 65535)::int4 % ${collection.partitions})$TABLESPACE;""" + } + + /** + * Calculate the distribution-partition into which to write the feature with the given feature-id. + * @param featureId the ID of the feature to locate the performance partition for. + * @return either the performance partition to put the feature into; _null_ if the table is not partitioned, features need to be written into the table itself. + */ + @JsName("getByFeatureId") + operator fun get(featureId: String): PgDistributionPartition? + = if (partitions.isEmpty()) null else partitions[partitionNumber(featureId) % partitions.size] + + @JsName("getByFeatureNumber") + operator fun get(featureNumber: Int64): PgDistributionPartition? + = if (partitions.isEmpty()) null else partitions[featureNumber.toInt() % partitions.size] + + override fun create(conn: PgConnection) { + super.create(conn) + for (partition in partitions) partition.create(conn) + } + + override fun createIndex(conn: PgConnection, index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.createIndex(conn, index) + } else { + super.createIndex(conn, index) + } + if (index !in indices) indices += index + } + + override fun addIndex(index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.addIndex(index) + } else { + super.addIndex(index) + } + if (index !in indices) indices += index + } + + override fun removeIndex(index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.removeIndex(index) + } else { + super.removeIndex(index) + } + if (index in indices) indices -= index + } + + override fun dropIndex(conn: PgConnection, index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.dropIndex(conn, index) + } else { + super.dropIndex(conn, index) + } + if (index in indices) indices -= index + } +} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistory.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistory.kt deleted file mode 100644 index 4185057486..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistory.kt +++ /dev/null @@ -1,73 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.psql - -import naksha.model.Version -import kotlin.js.JsExport -import kotlin.js.JsName -import kotlin.jvm.JvmField - -/** - * The HISTORY table, partitioned by [next_version][PgColumn.next_version]. - * @since 3.0 - * @see [PgTable] - * @see [PgHistoryYear] - */ -@JsExport -class PgHistory(val head: PgHead) : PgTable( - head.collection, "${head.collection.id}${PG_HST}", head.storageClass, false, - partitionByColumn = PgColumn.next_version -) { - /** - * All partitions, with key being the partition key (`next_version >> shift`). - */ - @JvmField - val years: MutableMap = mutableMapOf() - @JsName("getYear") - operator fun get(txn_next: Version): PgHistoryYear? = years[txn_next.year] - @JsName("setYear") - operator fun set(txn_next: Version, partition: PgHistoryYear) { - years[txn_next.year] = partition - } - - override fun create(conn: PgConnection) { - super.create(conn) - for (entry in years) entry.value.create(conn) - } - - fun createYear(conn: PgConnection, partitionKey: Int) { - var yearTable = years[partitionKey] - if (yearTable == null) { - yearTable = PgHistoryYear(this, partitionKey) - years[partitionKey] = yearTable - } - yearTable.create(conn) - for (index in indices) { - yearTable.createIndex(conn, index) - } - } - - fun addYear(partitionKey: Int) { - if (partitionKey !in years) { - val yearTable = PgHistoryYear(this, partitionKey) - years[partitionKey] = yearTable - for (index in indices) yearTable.addIndex(index) - } - } - - override fun addIndex(index: PgIndex) { - for (entry in years) entry.value.addIndex(index) - } - - override fun removeIndex(index: PgIndex) { - for (entry in years) entry.value.removeIndex(index) - } - - override fun createIndex(conn: PgConnection, index: PgIndex) { - for (entry in years) entry.value.createIndex(conn, index) - } - - override fun dropIndex(conn: PgConnection, index: PgIndex) { - for (entry in years) entry.value.dropIndex(conn, index) - } -} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryPartition.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryPartition.kt index a5a7104e37..b92aca53ec 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryPartition.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryPartition.kt @@ -2,17 +2,155 @@ package naksha.psql +import naksha.base.Int64 +import naksha.model.Naksha +import naksha.model.NakshaError.NakshaErrorCompanion.PARTITION_NOT_FOUND +import naksha.model.NakshaException +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.VERSION +import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent import kotlin.js.JsExport +import kotlin.js.JsName +import kotlin.jvm.JvmField /** - * A feature partition for performance optimisation. - * @property year the history year. - * @param index the index in the history year partitions array. + * A history partition for all features modified in a specific next-version range. * @since 3.0 - * @see [PgHistoryYear] + * @see [PgHistoryTable] + * @see [PgDistributionPartition] */ @JsExport -class PgHistoryPartition(val year: PgHistoryYear, index: Int) : PgTable( - year.collection, "${year.name}${PG_PART}${PgUtil.partitionSuffix(index)}", year.storageClass, false, - partitionOfTable = year, partitionOfValue = index -) \ No newline at end of file +class PgHistoryPartition( + /** + * The parent table, must be [PgHistoryTable]. + * @since 3.0 + */ + parent: PgHistoryTable, + + /** + * The partition-index of this partition, actually `{next-version} shr {shift}`. + * @since 3.0 + */ + @JvmField + val partitionIndex: Int +) : PgTable(parent.collection, parent.name + '$' + partitionIndex, parent) { + /** + * All distribution partitions; if not partitioned, then an empty array. + * @since 3.0 + */ + @JvmField + val partitions: Array = if (collection.partitions <= 1) emptyArray() else Array(collection.partitions) { + PgDistributionPartition(this, it) + } + + override fun CREATE_SQL(): String { + val (CREATE_TABLE, TABLESPACE) = CREATE_TABLE_and_TABLESPACE() + val toast_tuple_target:Int = collection.catalog.storage.adminCatalog.maxTupleSize + val parent = this.parent as PgHistoryTable + val ID = collection.column(Id) + val NEXT_VERSION = collection.column(NextVersion) + + // HISTORY-PARTITION is NOT distribution partitioned. + if (partitions.isEmpty()) return """$CREATE_TABLE $quotedName +PARTITION OF ${parent.quotedName} (${parent.CONSTRAINT(partitionIndex)}) +FOR VALUES FROM ($partitionIndex) TO (${partitionIndex+1}) +WITH (fillfactor=50,toast_tuple_target=$toast_tuple_target)$TABLESPACE; +CREATE INDEX ${quoteIdent(name, "\$i_version")} ON $quotedName USING btree ($VERSION, $NEXT_VERSION) INCLUDE ($FN, $ID);""" + + // HISTORY-PARTITION is distribution partitioned. + return """$CREATE_TABLE $quotedName +PARTITION OF ${parent.quotedName} +PARTITION BY RANGE ((($FN & 65535)::int4 % ${collection.partitions}) +FOR VALUES FROM ($partitionIndex) TO (${partitionIndex+1}) +WITH (fillfactor=50,toast_tuple_target=$toast_tuple_target)$TABLESPACE""" + } + + /** + * Calculates the partition-number from the given [feature-number][FN]. + * @param featureNumber the [feature-number][FN] from which to calculate the partition-number. + * @return the calculated partition-number. + * @since 3.0 + */ + @JsName("partitionNumberForFeatureNumber") + fun partitionNumber(featureNumber: Int64): Int = Naksha.partitionNumber(featureNumber) % collection.partitions + + /** + * Calculates the partition-number from the given [feature-id][Id]. + * @param featureId the [feature-id][Id] from which to calculate the partition-number. + * @return the calculated partition-number. + * @since 3.0 + */ + @JsName("partitionNumberForFeatureId") + fun partitionNumber(featureId: String): Int = Naksha.partitionNumber(featureId) % collection.partitions + + /** + * Calculate the distribution-partition into which to write the feature with the given feature-number. + * @param featureNumber the feature-number of the feature to return the distribution-partition for. + * @return either the distribution-partition to put the feature into or `null` if the table is not partitioned, features need to be written into the table itself. + */ + @JsName("getByFeatureNumber") + operator fun get(featureNumber: Int64): PgDistributionPartition? { + val partitions = this.partitions + if (partitions.isEmpty()) return null + val i = Naksha.partitionNumber(featureNumber) % collection.partitions + check(i >= partitions.size) { throw NakshaException(PARTITION_NOT_FOUND, "Partition $i not found in table $name") } + return partitions[i] + } + + /** + * Calculate the distribution-partition into which to write the feature with the given feature-id. + * @param featureId the feature-id of the feature to return the distribution-partition for. + * @return either the distribution-partition to put the feature into or `null` if the table is not partitioned, features need to be written into the table itself. + */ + @JsName("getByFeatureId") + operator fun get(featureId: String): PgDistributionPartition? { + val partitions = this.partitions + if (partitions.isEmpty()) return null + val i = Naksha.partitionNumber(featureId) % collection.partitions + check(i >= partitions.size) { throw NakshaException(PARTITION_NOT_FOUND, "Partition $i not found in table $name") } + return partitions[i] + } + + override fun create(conn: PgConnection) { + super.create(conn) + for (partition in partitions) partition.create(conn) + } + + override fun createIndex(conn: PgConnection, index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.createIndex(conn, index) + } else { + super.createIndex(conn, index) + } + if (index !in indices) indices += index + } + + override fun addIndex(index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.addIndex(index) + } else { + super.addIndex(index) + } + if (index !in indices) indices += index + } + + override fun removeIndex(index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.removeIndex(index) + } else { + super.removeIndex(index) + } + if (index in indices) indices -= index + } + + override fun dropIndex(conn: PgConnection, index: PgIndex) { + if (!partitions.isEmpty()) { + for (partition in partitions) partition.dropIndex(conn, index) + } else { + super.dropIndex(conn, index) + } + if (index in indices) indices -= index + } +} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryTable.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryTable.kt new file mode 100644 index 0000000000..86c3796ce2 --- /dev/null +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryTable.kt @@ -0,0 +1,128 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.psql + +import naksha.base.Int64 +import naksha.model.objects.StandardMembers.StandardMembers_C.Id +import naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.VERSION +import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent +import kotlin.js.JsExport +import kotlin.jvm.JvmField + +/** + * The _HISTORY_ table of a collection , partitioned by [next_version][PgColumn.NEXT_VERSION]. + * + * Features are moved into the history table when new states _([tuple][naksha.model.Tuple])_ are created in _HEAD_. In that case the previous [tuple][naksha.model.Tuple] is moved from _HEAD_ into _HISTORY_. When moving the [tuple][naksha.model.Tuple], its next-version property is set to the version of the new _head_ [tuple][naksha.model.Tuple], that replaces it. Within the history table the features are partitioned by this [next-version][naksha.model.objects.StandardMembers.StandardMembers_C.NextVersion]. With the default [shift][naksha.model.objects.NakshaCollection.shift] of 41 the partitioning effectively is done by calendar year of when the [tuple][naksha.model.Tuple] has become obsolete and was moved into _HISTORY_. + * @since 3.0 + * @see [PgHistoryPartition] + * @see [PgDistributionPartition] + * @see [PgTable] + */ +@JsExport +class PgHistoryTable( + /** The collection to which this HEAD table belongs. */ + collection: PgCollection, +) : PgTable(collection, collection.id + "\$hst", null) { + + /** + * All history partitions with the key being the partition-number and the value being the partition. + * + * Beware that history-partitions are named like `{name}$hst${nextVersion >> shift}` for example `foo$hst$1`. + * @since 3.0 + */ + @JvmField + val partitions: MutableMap = mutableMapOf() + + @Suppress("FunctionName") + internal fun CONSTRAINT(historyPartition:Int): String { + val ID = collection.column(Id) + val NEXT_VERSION = collection.column(NextVersion) + return """ + CONSTRAINT ${quoteIdent(name, "\$c_pkey")} PRIMARY KEY ($FN, $VERSION) INCLUDE ($NEXT_VERSION, $ID}), + CONSTRAINT ${quoteIdent(name, "\$c_nv")} CHECK ($NEXT_VERSION IS NOT NULL AND $NEXT_VERSION >= $VERSION), + CONSTRAINT ${quoteIdent(name, "\$c_nvr")} CHECK ((($NEXT_VERSION >> ${collection.shift})::int4) = $historyPartition), + CONSTRAINT ${quoteIdent(name, "\$c_id")} UNIQUE ($ID, $VERSION) INCLUDE ($NEXT_VERSION, $FN}), + CONSTRAINT ${quoteIdent(name, "\$c_fn")} CHECK (($FN < 0 AND $ID IS NOT NULL) OR ($FN >= 0 AND $ID IS NULL))""" + } + + @Suppress("FunctionName") + internal fun CONSTRAINT(historyPartition:Int, distributionPartition: Int): String { + return """${CONSTRAINT(historyPartition)}, + CONSTRAINT ${quoteIdent(name, "\$c_fnr")} CHECK ((($FN & 65535)::int4 % ${collection.partitions})=$distributionPartition) +""" + } + + override fun CREATE_SQL(): String { + val (CREATE_TABLE, TABLESPACE) = CREATE_TABLE_and_TABLESPACE() + val NEXT_VERSION = collection.column(NextVersion) + return """$CREATE_TABLE $quotedName (${columnDefinitions()}) +PARTITION BY RANGE ((($NEXT_VERSION >> ${collection.shift})::int4)) +WITH (fillfactor=100,toast_tuple_target=$toast_tuple_target) +$TABLESPACE""" + } + + /** + * Calculates the partition-number from the given [next-version][NextVersion]. + * @param nextVersion the [next-version][NextVersion] from which to calculate the partition-number. + * @return the calculated partition-number. + * @since 3.0 + */ + fun partitionNumber(nextVersion: Int64): Int = (nextVersion shr collection.shift).toInt() + + operator fun get(nextVersion: Int64): PgHistoryPartition? = partitions[partitionNumber(nextVersion)] + + operator fun set(nextVersion: Int64, partition: PgHistoryPartition) { + partitions[partitionNumber(nextVersion)] = partition + } + + override fun create(conn: PgConnection) { + super.create(conn) + for (entry in partitions) entry.value.create(conn) + } + + /** + * Create a new [PgHistoryPartition], if no such partition exists already. + * @param conn the connection to be used to modify the database. + * @param partitionNumber the partition-number of the partition to create. + * @return the [PgHistoryPartition] created or already existing. + * @see [PgCollection.historyPartitionNumberOf] + */ + fun createPartition(conn: PgConnection, partitionNumber: Int): PgHistoryPartition { + var partition = partitions[partitionNumber] + if (partition == null) { + partition = PgHistoryPartition(this, partitionNumber) + partitions[partitionNumber] = partition + } + partition.create(conn) + for (index in indices) { + partition.createIndex(conn, index) + } + return partition + } + + fun addPartition(partitionNumber: Int) { + if (partitionNumber !in partitions) { + val partition = PgHistoryPartition(this, partitionNumber) + partitions[partitionNumber] = partition + for (index in indices) partition.addIndex(index) + } + } + + override fun addIndex(index: PgIndex) { + for (entry in partitions) entry.value.addIndex(index) + } + + override fun removeIndex(index: PgIndex) { + for (entry in partitions) entry.value.removeIndex(index) + } + + override fun createIndex(conn: PgConnection, index: PgIndex) { + for (entry in partitions) entry.value.createIndex(conn, index) + } + + override fun dropIndex(conn: PgConnection, index: PgIndex) { + for (entry in partitions) entry.value.dropIndex(conn, index) + } +} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryYear.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryYear.kt deleted file mode 100644 index f0237ece7b..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgHistoryYear.kt +++ /dev/null @@ -1,96 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.psql - -import naksha.model.NakshaError.NakshaErrorCompanion.PARTITION_NOT_FOUND -import naksha.model.NakshaException -import kotlin.js.JsExport -import kotlin.jvm.JvmField - -/** - * A history partition for a specific partition-key of the history. There should always be a partition for the current - * key value and the next one. With the default [shift][naksha.model.objects.NakshaCollection.shift] of 41 the key - * value equals the calendar year, so typically the current year and the next year. - * - * Beware that the history is not partitioned by the year when the transaction happened, but by the moment the - * transaction was updated, and moved into the history. This is done, because we have the yearly partitions for - * garbage collection, and if the state of a row is moved into history, from this moment on we want to keep it - * for some time (e.g. one year), otherwise a feature that was updated 3 years ago the last time, and then moved - * into history, would be removed from history instantly. - * @property history the history table. - * @param partitionKey the value of `(next_version >> shift)` that identifies this partition. - * @since 3.0 - * @see [PgHistory] - * @see [PgHistoryPartition] - */ -@JsExport -class PgHistoryYear(val history: PgHistory, val partitionKey: Int) : PgTable( - history.collection, - "${history.name}${PG_S}${partitionKey}", - history.storageClass, - false, - partitionOfTable = history, - partitionOfValue = partitionKey, - partitionByColumn = history.head.partitionByColumn, - partitionCount = history.head.partitionCount -) { - /** - * The performance partitions. - */ - @JvmField - val partitions: Array = Array(history.head.partitions.size) { PgHistoryPartition(this, it) } - - /** - * Calculate the performance partition into which to write the feature with the given ID. - * @param featureId the ID of the feature to locate the performance partition for. - * @return either the performance partition to put the feature into; _null_ if the table is not partitioned, features need to be written into the table itself. - */ - operator fun get(featureId: String): PgHistoryPartition? { - val partitions = this.partitions - if (partitions.isEmpty()) return null - val i = PgUtil.partitionNumber(featureId) % partitions.size - check(i >= partitions.size) { throw NakshaException(PARTITION_NOT_FOUND, "Partition $i not found in table $name") } - return partitions[i] - } - - override fun create(conn: PgConnection) { - super.create(conn) - for (partition in partitions) partition.create(conn) - } - - override fun createIndex(conn: PgConnection, index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.createIndex(conn, index) - } else { - super.createIndex(conn, index) - } - if (index !in indices) indices += index - } - - override fun addIndex(index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.addIndex(index) - } else { - super.addIndex(index) - } - if (index !in indices) indices += index - } - - override fun removeIndex(index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.removeIndex(index) - } else { - super.removeIndex(index) - } - if (index in indices) indices -= index - } - - override fun dropIndex(conn: PgConnection, index: PgIndex) { - if (this.partitionByColumn != null) { - for (partition in partitions) partition.dropIndex(conn, index) - } else { - super.dropIndex(conn, index) - } - if (index in indices) indices -= index - } -} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgIndex.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgIndex.kt index 035eb204b1..8e2425f106 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgIndex.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgIndex.kt @@ -1,834 +1,108 @@ package naksha.psql -import naksha.base.AtomicMap -import naksha.base.JsEnum -import naksha.base.fn.Fx2 +import naksha.model.illegalArg import naksha.model.objects.Index -import naksha.model.objects.IndexType -import naksha.model.request.query.SortOrder -import naksha.model.request.query.SortOrder.SortOrderCompanion.DESCENDING +import naksha.model.objects.MemberType import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent -import naksha.psql.PgColumn.PgColumnCompanion.id as c_id -import naksha.psql.PgColumn.PgColumnCompanion.fn as c_fn -import naksha.psql.PgColumn.PgColumnCompanion.version as c_version -import naksha.psql.PgColumn.PgColumnCompanion.next_version as c_next_version -import naksha.psql.PgColumn.PgColumnCompanion.app_id as c_app_id -import naksha.psql.PgColumn.PgColumnCompanion.author as c_author -import naksha.psql.PgColumn.PgColumnCompanion.author_ts as c_author_ts -import naksha.psql.PgColumn.PgColumnCompanion.updated_at as c_updated_at -import naksha.psql.PgColumn.PgColumnCompanion.geo as c_geo -import naksha.psql.PgColumn.PgColumnCompanion.here_tile as c_here_tile -import naksha.psql.PgColumn.PgColumnCompanion.tags as c_tags -import naksha.psql.PgColumn.PgColumnCompanion.ref_point as c_ref_point -import naksha.psql.PgColumn.PgColumnCompanion.ft as c_ft -import naksha.psql.PgColumn.PgColumnCompanion.cv0 as c_cv0 -import naksha.psql.PgColumn.PgColumnCompanion.cv1 as c_cv1 -import naksha.psql.PgColumn.PgColumnCompanion.cv2 as c_cv2 -import naksha.psql.PgColumn.PgColumnCompanion.cv3 as c_cv3 -import naksha.psql.PgColumn.PgColumnCompanion.cs0 as c_cs0 -import naksha.psql.PgColumn.PgColumnCompanion.cs1 as c_cs1 -import naksha.psql.PgColumn.PgColumnCompanion.cs2 as c_cs2 -import naksha.psql.PgColumn.PgColumnCompanion.cs3 as c_cs3 -import naksha.psql.PgColumn.PgColumnCompanion.gbn as c_gbn import kotlin.js.JsExport -import kotlin.js.JsName -import kotlin.js.JsStatic import kotlin.jvm.JvmField -import kotlin.jvm.JvmStatic -import kotlin.reflect.KClass /** - * The base class for indices. We have different kind of indices, but there is a general rule for all fuzzy indices. - * - * ## Warning - * Identifiers in Postgres are limited to 63 byte (and otherwise will be truncated)! - * - * For HEAD tables the index-name is `{collection-name}{$head$}{index-name}`, e.g. - * `mycol$head$tnu`. For history leaf partitions it is `{leaf-table-name}${index-name}`, e.g. - * `mycol$hst$2026$tnu`. The postfix `$hst$????` is at most 10 byte (year up to 4 digits), so the - * collection name can be up to `63 - 10 - 1 - 3 = 49` byte to fit a 3-character index id. - * - * **This is why we use only three digit identifiers for the indices!** + * The special PostgreSQL internal class that represents an [indices][Index], generated from [custom indices][Index]. * + * The mandatory indices are intrinsic and not exposed as `PgIndex`. Therefore, indices are only those indices that users needs, not what is necessary. */ @Suppress("OPT_IN_USAGE", "MemberVisibilityCanBePrivate") @JsExport -open class PgIndex : JsEnum() { - // TODO: We need to allow `CREATE INDEX CONCURRENTLY`, when the index is not unique. - // However, this can only be done, when being outside of a transaction! - // We need to add special support for index modification outside of transactions. - // So, when a client modifies a collection, we can use a second connection in auto-commit, and - // create missing indices with a special query. - // Storage-API then needs to create a table brittle, without indices, except for minimal ones, - // then after importing, it should switch tables to logged, then add indices, which should be - // done concurrently or the client needs to wait until the index is build, but then we need - // to add an very large timeout. - protected fun sql(using: String, table: PgTable, unique: Boolean, addFillFactor: Boolean, where: String?, includes: List = emptyList()): String { - val filteredIncludes = if (PgTable.isAnyHead(table.name) || PgTable.isMeta(table.name)) - includes.filter { it !== c_next_version } - else includes - val includeClause = if (filteredIncludes.isEmpty()) "" else " INCLUDE (${filteredIncludes.joinToString(", ")})" - return """ -CREATE ${if (unique) "UNIQUE INDEX" else "INDEX "} IF NOT EXISTS ${quoteIdent(id(table))} ON ${table.quotedName} -USING $using$includeClause -${if (addFillFactor) "WITH (fillfactor="+if (table.isVolatile) "80)" else "100)" else ""} ${table.TABLESPACE} -${if (where==null) "" else "WHERE $where"};""" - } - - companion object PgIndex_C { - /** - * The primary key about the [fn][PgColumn.fn] (and, for HISTORY-side tables, [version][PgColumn.version]). - * - * This entry is not used directly to create the index, its only formally here because the column itself has the attribute `PRIMARY KEY`, which is important for joins, replication, and such things. This constraint will always be named automatically by postgres as `table_pkey`. The actual PK DDL is emitted from [PgTable]; the [createFn] below is informational only. - * - * - Always added to all tables as PRIMARY KEY! - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val fn_pkey = def(PgIndex::class, "tnu") { self -> - self.name = "fn_unique" - self.internal = true - self.columns = listOf(c_fn) - self.naturalOrder = listOf(DESCENDING) - self.includes = emptyList() - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree ($c_fn DESC)""", - table, unique = true, addFillFactor = true, where = null - ) - ).close() - } - } - - /** - * A unique index above the [id][PgColumn.id], including [fn][PgColumn.fn], [version][PgColumn.version] and [next_version][PgColumn.next_version] column. - * - * Conditional: `WHERE id IS NOT NULL` — rows where `fn >= 0` have `id = NULL` (numeric features) and are excluded. - * - * - Automatically added to [HEAD][PgHead], [DELETED][PgDeleted], and [META][PgMeta]. - * - Must not be added to [HISTORY][PgHistory]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val id_unique = def(PgIndex::class, "idu") { self -> - self.name = "id_unique" - self.internal = true - self.columns = listOf(c_id) - self.naturalOrder = listOf(DESCENDING) - self.includes = listOf(c_fn, c_version, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree ($c_id text_pattern_ops DESC)""", - table, unique = true, addFillFactor = true, where = "$c_id IS NOT NULL", - includes = listOf(c_fn, c_version, c_next_version) - ) - ).close() - } - } - - /** - * A non-unique index above the [id][PgColumn.id], [fn][PgColumn.fn] and [version][PgColumn.version], including [next_version][PgColumn.next_version] column. - * - * Conditional: `WHERE id IS NOT NULL` — rows where `fn >= 0` have `id = NULL` (numeric features) and are excluded. - * - * - Automatically added to [HISTORY][PgHistory]. - * - Must not be added to [HEAD][PgHead], [DELETED][PgDeleted], and [META][PgMeta]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val id = def(PgIndex::class, "idi") { self -> - self.name = "id" - self.internal = true - self.columns = listOf(c_id, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree ($c_id text_pattern_ops DESC, $c_fn DESC, $c_version DESC)""", - table, unique = false, addFillFactor = true, where = "$c_id IS NOT NULL", - includes = listOf(c_next_version) - ) - ).close() - } - } - - /** - * A non-unique index above the [version][PgColumn.version]. - * - * - Always added to all tables. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val version = def(PgIndex::class, "ver") { self -> - self.name = "version" - self.internal = true - self.columns = listOf(c_version) - self.naturalOrder = listOf(DESCENDING) - self.includes = listOf(c_fn, c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree ($c_version DESC)""", - table, unique = false, addFillFactor = true, where = null, - includes = listOf(c_fn, c_id, c_next_version) - ) - ).close() - } - } - - /** - * A unique index above the transaction-number _(aka [version][PgColumn.version])_, including [id][PgColumn.id], [fn][PgColumn.fn] and [next_version][PgColumn.next_version] column. - * - * - Automatically added to all [TRANSACTIONS][PgTransactions] tables. - * - Must not be added to any other table. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val txn_unique = def(PgIndex::class, "txn") { self -> - self.name = "txn_unique" - self.internal = true - self.columns = listOf() - self.naturalOrder = listOf(DESCENDING) - self.includes = listOf(c_id, c_fn, c_version, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree (($c_version & -4) DESC)""", - table, unique = true, addFillFactor = true, where = null, - includes = listOf(c_fn, c_version, c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [here_tile][PgColumn.here_tile], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * - * Ordered by: - * - `here_tile` DESC - * - `fn` DESC - * - `version` DESC - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val here_tile = def(PgIndex::class, "hti") { self -> - self.name = "here_tile" - self.columns = listOf(c_here_tile, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_here_tile DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_here_tile IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [app_id][PgColumn.app_id], [updated_at][PgColumn.updated_at], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * - * Ordered by - * - `app_id` DESC - * - `updated_at` DESC - * - `fn` DESC - * - `version` DESC - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val app_id = def(PgIndex::class, "aid") { self -> - self.name = "app_id" - self.columns = listOf(c_app_id, c_updated_at, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree ($c_app_id text_pattern_ops DESC, $c_updated_at DESC, $c_fn DESC, $c_version DESC)""", - table, unique = false, addFillFactor = true, where = "$c_app_id IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above the `naksha_author(`[author][PgColumn.author], [app_id][PgColumn.app_id]`)`, `naksha_author_ts(`[author_ts][PgColumn.author_ts], [updated_at][PgColumn.updated_at]`)`, [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * - * Ordered by: - * - `naksha_author(author, app_id)` DESC - * - `naksha_author_ts(author_ts, updated_at)` DESC - * - `fn` DESC - * - `version` DESC - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val author = def(PgIndex::class, "ath") { self -> - self.name = "author" - self.columns = listOf(c_author, c_author_ts, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """btree (naksha_author($c_author, $c_app_id) text_pattern_ops DESC, naksha_author_ts($c_author_ts, $c_updated_at) DESC, $c_fn DESC, $c_version DESC)""", - table, unique = false, addFillFactor = true, where = "naksha_author($c_author, $c_app_id) IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * A [GIN](https://www.postgresql.org/docs/current/gin.html) index above the raw `jsonb` [tags][PgColumn.tags] column, plus [fn][PgColumn.fn], [version][PgColumn.version], and [next_version][PgColumn.next_version]. - * - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val tags = def(PgIndex::class, "tag") { self -> - self.name = "tags" - self.columns = listOf(c_tags, c_fn, c_version, c_next_version) - self.createFn = Fx2 { conn, table -> - val nv = if (PgTable.isAnyHead(table.name) || PgTable.isMeta(table.name)) "" else ", $c_next_version" - conn.execute( - self.sql( - """gin ($c_tags, $c_fn, $c_version$nv)""", - table, unique = false, addFillFactor = false, where = "$c_tags IS NOT NULL" - ) - ).close() - } - } - - /** - * A two-dimensional [SP-GIST](https://www.postgresql.org/docs/current/spgist.html) index above `naksha_ref_point(`[PgColumn.ref_point]`)`. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val ref_point = def(PgIndex::class, "ref") { self -> - self.name = "ref_point" - self.columns = listOf(c_ref_point) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """spgist (naksha_ref_point($c_ref_point))""", - table, unique = false, addFillFactor = true, where = "naksha_ref_point($c_ref_point) IS NOT NULL" - ) - ).close() - } - } - - /** - * A two-dimensional [GIST](https://www.postgresql.org/docs/current/gist.html) index above `naksha_2d(`[PgColumn.geo]`)`. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val gist_geo = def(PgIndex::class, "g2d") { self -> - self.name = "gist_geo" - self.columns = listOf(c_geo, c_fn, c_version, c_next_version) - self.createFn = Fx2 { conn, table -> - val nv = if (PgTable.isAnyHead(table.name) || PgTable.isMeta(table.name)) "" else ", $c_next_version" - conn.execute( - self.sql( - """gist (naksha_2d($c_geo), $c_fn, $c_version$nv)""", - table, unique = false, addFillFactor = true, where = "naksha_2d($c_geo) IS NOT NULL" - ) - ).close() - } - } - - /** - * A two-dimensional [GIST](https://www.postgresql.org/docs/current/gist.html) index above `naksha_2d(`[PgColumn.geo]`)`. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val spgist_geo = def(PgIndex::class, "s2d") { self -> - self.name = "spgist_geo" - self.columns = listOf(c_geo) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - """spgist (naksha_2d($c_geo))""", - table, unique = false, addFillFactor = true, where = "naksha_2d($c_geo) IS NOT NULL" - ) - ).close() - } - } - - /** - * Index above [feature-type][PgColumn.ft], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val ft = def(PgIndex::class, "ft") { self -> - self.name = "feature_type" - self.columns = listOf(c_ft, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_ft text_pattern_ops DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_ft IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - }.alias("feature_type").alias("featureType") - - /** - * Index above [cv0][PgColumn.cv0], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cv0 = def(PgIndex::class, "cv0") { self -> - self.name = "cv0" - self.columns = listOf(c_cv0, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cv0 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cv0 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cv1][PgColumn.cv1], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cv1 = def(PgIndex::class, "cv1") { self -> - self.name = "cv1" - self.columns = listOf(c_cv1, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cv1 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cv1 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cv2][PgColumn.cv2], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cv2 = def(PgIndex::class, "cv2") { self -> - self.name = "cv2" - self.columns = listOf(c_cv2, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cv2 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cv2 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cv3][PgColumn.cv3], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cv3 = def(PgIndex::class, "cv3") { self -> - self.name = "cv3" - self.columns = listOf(c_cv3, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cv3 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cv3 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cs0][PgColumn.cs0], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cs0 = def(PgIndex::class, "cs0") { self -> - self.name = "cs0" - self.columns = listOf(c_cs0, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cs0 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cs0 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cs1][PgColumn.cs1], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cs1 = def(PgIndex::class, "cs1") { self -> - self.name = "cs1" - self.columns = listOf(c_cs1, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cs1 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cs1 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cs2][PgColumn.cs2], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cs2 = def(PgIndex::class, "cs2") { self -> - self.name = "cs2" - self.columns = listOf(c_cs2, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cs2 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cs2 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Index above [cs3][PgColumn.cs3], [fn][PgColumn.fn] and [version][PgColumn.version], including [id][PgColumn.id] and [next_version][PgColumn.next_version]. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val cs3 = def(PgIndex::class, "cs3") { self -> - self.name = "cs3" - self.columns = listOf(c_cs3, c_fn, c_version) - self.naturalOrder = listOf(DESCENDING, DESCENDING, DESCENDING) - self.includes = listOf(c_id, c_next_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_cs3 DESC, $c_fn DESC, $c_version DESC)", - table, unique = false, addFillFactor = true, where = "$c_cs3 IS NOT NULL", - includes = listOf(c_id, c_next_version) - ) - ).close() - } - } - - /** - * Conditional non-unique index above the [gbn][PgColumn.gbn], including [fn][PgColumn.fn] and [version][PgColumn.version]. - * - * Conditional: `WHERE gbn IS NOT NULL` — rows that do not reference a global book are excluded. - * - * - Automatically added to all tables as a mandatory index. - * @see [PgAdminMap.createPgCollection] - */ - @JvmField - @JsStatic - val gbn_idx = def(PgIndex::class, "gbn") { self -> - self.name = "gbn" - self.internal = true - self.columns = listOf(c_gbn) - self.naturalOrder = listOf(DESCENDING) - self.includes = listOf(c_fn, c_version) - self.createFn = Fx2 { conn, table -> - conn.execute( - self.sql( - "btree ($c_gbn DESC)", - table, unique = false, addFillFactor = true, where = "$c_gbn IS NOT NULL", - includes = listOf(c_fn, c_version) - ) - ).close() - } - } - - /** - * Truncates the identifier to the minimal size that is guaranteed. - * @param id the index identifier. - * @return the identifier truncated to the minimal guaranteed length. - */ - @JvmStatic - @JsStatic - fun truncate(id: String): String = if (id.length > 3) id.substring(0, 3) else id - - /** - * Find the index by name or _relname_. - * @param name the official [name] or the relation name (`relname`), as returned by the database from the `pg_class` table (with `relkind` being `i`). - * @return the index, if it exists. - */ - @JvmStatic - @JsStatic - fun of(name: String): PgIndex? { - val existing = getDefined(name, PgIndex::class) ?: indexByName[name] - if (existing != null) return existing - // Try new HEAD-index naming: {tableName}$head${indexText} - val headSepStart = name.lastIndexOf(PG_HEAD_IDX) - val start: Int - val sepLen: Int - if (headSepStart >= 0) { - start = headSepStart - sepLen = PG_HEAD_IDX.length - } else { - // Fall back to legacy $i_ prefix (custom indices and TXN-era names) - val legacyStart = name.lastIndexOf(PG_IDX) - if (legacyStart >= 0) { - start = legacyStart - sepLen = PG_IDX.length - } else { - // History/partition leaf naming: last $ segment - val lastDollar = name.lastIndexOf(PG_S) - if (lastDollar < 0) return null - start = lastDollar - sepLen = PG_S.length - } - } - // This is a hack for PostgresQL, which will truncate identifiers to 63 byte. - // Therefore, we know that name is limited to 63 characters, which may have truncated the index identifier. - // So we extract what is left from the index identifier and the compare it against all enumeration values. - // Note: It could only have truncated the last byte or many more, dependent on how long the collection id is! - val pg_truncated_id = name.substring(start + sepLen) - for (e in iterate(PgIndex::class)) if (e.text.startsWith(pg_truncated_id)) return e - return null - } - - /** - * Indices by official name. - * @since 3.0.0 - */ - private val indexByName = AtomicMap() - init { - // Sanity check. - val byName = indexByName - val map = HashMap() - for (index in iterate(PgIndex::class)) { - val pg_truncated_id = truncate(index.text) - if (pg_truncated_id in map) { - val c = map[pg_truncated_id]!! - throw Error("Conflict, the index ${index.text} has the same short name as ${c.text}: $pg_truncated_id") - } - map[pg_truncated_id] = index - - val e = byName.putIfAbsent(index.name, index) - if (e != null) throw Error("Conflict, the index ${index.text} has the same official name as ${e.text}: ${e.name}") - } - } - - /** - * The list of default index names to be are added, when _null_ is provided as index list to create. - */ - @JvmField - @JsStatic - var DEFAULT_INDICES = listOf( - id, - here_tile, - app_id, - author, - tags, - ft, - cv0, cv1, cv2, cv3, - cs0, cs1, cs2, cs3, - ref_point, - gist_geo, - ) - - /** - * Converts a [PgIndex] to an [Index] model object, preserving [PgIndex.internal]. - */ - internal fun pgIndexToIndex(pgIdx: PgIndex): Index { - val idx = Index() - idx.name = pgIdx.name - idx.internal = pgIdx.internal - // Determine index type from the PgIndex columns (heuristic: geo columns → SPATIAL, no columns or btree → BTREE) - idx.type = when { - pgIdx === gist_geo || pgIdx === spgist_geo || pgIdx === ref_point -> IndexType.SPATIAL - // The built-in tags index is a GIN index over the `tags` member, which is a SET - // (JSON array of unique strings) by default — matches StandardIndices.Tags. - pgIdx === tags -> IndexType.SET - else -> IndexType.BTREE - } - val cols = naksha.base.StringList() - for (c in pgIdx.columns) cols.add(c.name) - idx.on = cols - return idx - } - - /** - * The mandatory [Index]es that the storage always injects into every collection. - * These are storage-managed (`internal = true`) and must not be declared by clients. - * - * - `fn_pkey`: PRIMARY KEY on `fn` - * - `id_unique`: UNIQUE on `id` (HEAD/DELETED/META tables only) - * - `id`: non-unique index on `id` (HISTORY tables only) - * - `version`: index on `version` - * - `gbn`: conditional index on `gbn` WHERE `gbn IS NOT NULL` - * @since 3.0 - */ - @JsStatic - val mandatoryIndices: List by lazy { - listOf(fn_pkey, id_unique, id, version, gbn_idx).map { pgIndexToIndex(it) } - } - - /** - * The default [Index]es injected when the client does **not** provide an [indices][naksha.model.objects.NakshaCollection.indices] - * list (backward-compatible full schema). These correspond to all non-internal entries in [DEFAULT_INDICES]. - * @since 3.0 - */ - @JsStatic - val defaultIndices: List by lazy { - DEFAULT_INDICES.filter { !it.internal }.map { pgIndexToIndex(it) } - } - } - +data class PgIndex( /** - * If the index is internal, that means it is not intentionally manageable from clients. + * The collection unique name of the index. * @since 3.0 */ - var internal: Boolean = false - private set - - private var _name: String? = null + @JvmField + val name: String, /** - * The official index name to be used in [naksha.model.objects.NakshaCollection.indices]. - * @since 3.0.0 + * The columns to index. + * @since 3.0 */ - var name: String - get() = _name ?: text - protected set(value) { - _name = value - } + @JvmField + var on: Array, /** - * Returns the unique identifier of this index in the given table. - * - * - HEAD / META / DELETED root tables → `{tableName}$head${indexName}` - * - History / performance-partition leaf tables → `{tableName}${PG_S}${indexName}` - * (the leaf table name already carries the `$hst$` context) - * - * @param table the table for which to generate the unique index name. - * @return the unique identifier of this index in the given table. + * The columns to include into the index to improve queries. + * @since 3.0 */ - fun id(table: PgTable): String { - val sep = if (PgTable.isAnyHistory(table.name) || PgTable.isAnyDeleted(table.name)) - PG_S - else - PG_HEAD_IDX - val id = "${table.name}${sep}${text}" - return if (id.length > 63) id.substring(0, 63) else id - } + @JvmField + var includes: Array = emptyArray() +) { - /** - * Returns the unique identifier of this index in the table with the given name. - * - * - HEAD / META / DELETED root tables → `{tableName}$head${indexName}` - * - History / performance-partition leaf tables → `{tableName}${PG_S}${indexName}` - * - * @param tableName the name of the table for which to generate the unique index name. - * @return the unique identifier of this index in the given table. - */ - @JsName("idByTableName") - fun id(tableName: String): String { - val sep = if (PgTable.isAnyHistory(tableName) || PgTable.isAnyDeleted(tableName)) - PG_S - else - PG_HEAD_IDX - val id = "${tableName}${sep}${text}" - return if (id.length > 63) id.substring(0, 63) else id + companion object PgIndex_C { + private fun indexAndOpsOf(column: PgColumn, indexName: String): Pair = when (column.memberType) { + MemberType.INT8, + MemberType.INT16, + MemberType.INT32, + MemberType.INT64, + MemberType.FLOAT32, + MemberType.FLOAT64, + MemberType.BYTE_ARRAY -> Pair("btree", "") + MemberType.STRING -> Pair("btree", " COLLATE \"C\" text_pattern_ops") + MemberType.SPATIAL -> Pair("gist_btree", " gist_geometry_ops_2d") + MemberType.TAG_MAP -> Pair("gin", " jsonb_ops") + MemberType.TAG_MAP_FROM_ARRAY, + MemberType.TAG_LIST -> Pair("gin", " array_ops") + else -> throw illegalArg("The member type ${column.memberType} of column '$column' of index '$indexName' is not a valid index target") + } } - /** - * The columns (in order) which are part of the index. - * - * This is only informational purpose, because the index can be much more complicated, for example it could be a partial index, and it does not contain columns only included in the index, see [includes]. - */ - var columns: List = emptyList() - protected set - - /** - * The natural sort order of the index, should hold one entry for each one in [columns]. - * - * **Note**: If the sort-order is empty, but [columns] is not, this means that the index does not support sorting, e.g. `GIN` or `GIST` indices. - */ - var naturalOrder: List = emptyList() - protected set - - /** - * The columns being included only in the index. - */ - var includes: List = emptyList() - protected set - - protected var createFn: Fx2? = null internal fun create(conn: PgConnection, table: PgTable) { - val createFn = this.createFn - check(createFn != null) { "This index does not support `create` operation" } - return createFn.call(conn, table) + if (on.isEmpty()) throw illegalArg("Index without target columns: $name") + val primaryColumn = on.first() + val (primaryIndex, primaryDeclaration) = indexAndOpsOf(primaryColumn, name) + val secondaries = mutableListOf>() + for (i in 0 ..< on.size) { + val pgColumn = on[i] + val secondary = indexAndOpsOf(pgColumn, name) + if (secondary.first != "btree" || !primaryIndex.contains(secondary.first)) { + throw illegalArg("The member #$i ($pgColumn) can not be used as secondary index element, only primitives are allows") + } + secondaries.add(secondary) + } + val includeClause = if (includes.isEmpty()) "" else " INCLUDE (${includes.joinToString(", ") { column -> + val (index, _) = indexAndOpsOf(column, "include") + if (index != "btree") throw illegalArg("The include of column $column is not possible, because it is no primitive") + column.ident + }})" + val indexIdent = quoteIdent(table.name, "\$i_", table.name) + val fillFactor = if (PgTable.isAnyHead(table.name)) "(fillfactor=50)" else "(fillfactor=100)" + val sql = """CREATE INDEX IF NOT EXISTS $indexIdent +ON ${table.quotedName} +USING $primaryIndex ($primaryDeclaration${secondaries.joinToString(", ") { secondary -> secondary.second }})$includeClause +WITH $fillFactor""" // like "gin jsonb_ops" + conn.execute(sql).close() } - protected var dropFn: Fx2? = null - internal fun drop(conn: PgConnection, table: PgTable) { - dropFn?.call(conn, table) ?: conn.execute("DROP INDEX IF EXISTS ${quoteIdent(id(table))} CASCADE").close() + internal fun drop(conn: PgConnection, tableName: String) { + val indexName = quoteIdent(tableName, "\$i_", tableName) + conn.execute("DROP INDEX IF EXISTS $indexName CASCADE").close() } - @Suppress("NON_EXPORTABLE_TYPE") - override fun namespace(): KClass = PgIndex::class + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as PgIndex + if (name != other.name) return false + if (!on.contentEquals(other.on)) return false + if (!includes.contentEquals(other.on)) return false + return true + } - override fun initClass() { - register(PgIndex::class) + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + on.contentHashCode() + result = 31 * result + includes.contentHashCode() + return result } -} \ No newline at end of file +} diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMap.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMap.kt deleted file mode 100644 index e833bebced..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMap.kt +++ /dev/null @@ -1,416 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.psql - -import naksha.base.* -import naksha.base.Platform.PlatformCompanion.logger -import naksha.model.Naksha -import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL -import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL_NUMBER -import naksha.model.Naksha.NakshaCompanion.BOOKS_COL -import naksha.model.Naksha.NakshaCompanion.BOOKS_COL_NUMBER -import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL -import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_NUMBER -import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL -import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL_NUMBER -import naksha.model.NakshaError -import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE -import naksha.model.NakshaException -import naksha.model.objects.NakshaCollection -import naksha.model.objects.NakshaMap -import naksha.psql.PgColumn.PgColumnCompanion.headColumns -import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent -import naksha.psql.PgUtil.PgUtilCompanion.quoteLiteral -import kotlin.js.JsExport -import kotlin.jvm.JvmField - -/** - * A map stores collections. - */ -@JsExport -open class PgMap internal constructor( - /** - * The reference to the storage. - * @since 3.0.0 - */ - open val storage: PgStorage, - - /** - * The HEAD state of the map. - * @since 3.0.0 - */ - nakshaMap: NakshaMap, - - /** - * The map-id. - * @since 3.0 - */ - val id: String = nakshaMap.id, - - /** - * The map-number. - * @since 3.0 - */ - val number: Int = nakshaMap.number -) { - /** - * The map-identifier quoted optionally in double quotes. - * @since 3.0 - */ - @JvmField - val quotedId = quoteIdent(id) - - /** - * The _HEAD_ state of the map. - * - * ### Note - * If the map is deleted, this value stays unmodified, because the [PgMap] will be removed from caching. However, if only the _HEAD_ state of the map is modified, so basically an `UPDATE` is done, the _HEAD_ reference is replaced on-the-fly. - * @since 3.0 - */ - val headRef = AtomicNonNullRef(nakshaMap) - - /** - * Reads [headRef]. - * @see [headRef] - * @since 3.0 - */ - val head: NakshaMap - get() = headRef.get() - - private var _collections: PgCollection? = null - - /** - * The collection's collection of the map _(`naksha~collections` aka `0`)_. - * @since 3.0 - * @see [createPgCollection] - * @see [getPgCollectionById] - * @see [getPgCollectionByNumber] - * @see [deletePgCollection] - */ - val collections: PgCollection - get() { - var c = _collections - if (c == null) { - c = PgCollection(this, NakshaCollection().withMapId(id).withId(COLLECTIONS_COL)) - _collections = c - } - return c - } - - protected val collectionCache = AtomicMap() - protected val collectionNumberById = AtomicMap() - - protected fun storeCollection(collection: PgCollection) { - collectionNumberById[collection.id] = collection.number - // TODO: Improve this, we should keep the PgCollection that has the higher version! - collectionCache[collection.number] = collection - } - - internal fun invalidateCollection(collection: PgCollection) { - collectionCache.remove(collection.number, collection) - //collectionNumberById.remove(collection.id, collection.number) - } - - /** - * Returns the `search_path` so that this map is on the top, followed by `naksha~admin`, `topology`, `hint_plan`, `public`. - * @return the `search_path` so that this map is on the top, followed by `naksha~admin`, `topology`, `hint_plan`, `public`. - */ - fun getSearchPath(): String = if (this is PgAdminMap) { - "SET search_path = \"naksha~admin\", topology, hint_plan, public" - } else { - "SET search_path = ${quotedId}, \"naksha~admin\", topology, hint_plan, public" - } - - /** - * Sets the `search_path` for the current transaction, so until `commit` or `rollback`. - * @param conn the connection where to set the search path. - * @since 3.0 - * @see [getSearchPath] - */ - fun setSearchPath(conn: PgConnection) { - conn.execute(getSearchPath()).close() - } - - /** - * Create a new [collection][PgCollection] using the given connection, and return it. - * - * ### Note - * - This method does not commit the given connection, therefore the collection is not yet persisted, but can be used through the given connection. - * - The method does not insert the corresponding entry into the collection's collection, this must be done upfront by the caller. - * - * - Throws [NakshaError.MAP_NOT_FOUND] if the given map does not exist _(anymore)_. - * - Throws [NakshaError.COLLECTION_EXISTS] if such a collection exists already in the given map. - * @param conn the connection to use to access the database. - * @param collection the collection to create. - * @return the created map. - * @since 3.0 - */ - open fun createPgCollection(conn: PgConnection, collection: PgCollection) { - // Ensure that all tables and indices are created in the correct map! - setSearchPath(conn) - val NOW = Epoch() - - // The indices list drives which optional indices are created: - // - null → backward-compatible: all DEFAULT_INDICES (non-internal), but only when members is also null - // (when members is explicitly set, only custom indices from the indices list are created) - // - non-null → only the client-declared (already stripped of internal entries by normalizeCollection) - // Mandatory/internal indices (id_unique, txn_unique, id, version, gbn) are always created below - // and are not present in either list. - val optionalIndices = collection.head.indices - val membersExplicit = collection.head.members != null - val defaultPgIndices: List = if (optionalIndices == null && !membersExplicit) PgIndex.DEFAULT_INDICES.filter { !it.internal } else emptyList() - - /** Creates one optional index on [table]: delegates to createIndex for known PgIndex names, - * or to createCustomIndex for user-defined names. */ - fun createOptionalIndex(table: PgTable, idx: naksha.model.objects.Index) { - val pgIdx = PgIndex.of(idx.name) - if (pgIdx != null) table.createIndex(conn, pgIdx) - else table.createCustomIndex(conn, idx) - } - - val head = collection.headTable - head.create(conn) - head.createIndex(conn, PgIndex.id_unique) - head.createIndex(conn, PgIndex.version) - head.createIndex(conn, PgIndex.gbn_idx) - for (index in defaultPgIndices) head.createIndex(conn, index) - if (optionalIndices != null) for (idx in optionalIndices) if (idx != null) createOptionalIndex(head, idx) - - val meta = collection.metaTable - if (meta != null) { - meta.create(conn) - meta.createIndex(conn, PgIndex.id_unique) - meta.createIndex(conn, PgIndex.version) - meta.createIndex(conn, PgIndex.gbn_idx) - for (index in defaultPgIndices) meta.createIndex(conn, index) - if (optionalIndices != null) for (idx in optionalIndices) if (idx != null) createOptionalIndex(meta, idx) - } - - val history = collection.historyTable - if (history != null) { - history.create(conn) - history.createYear(conn, NOW.year) - history.createYear(conn, NOW.year + 1) - history.createIndex(conn, PgIndex.id) - history.createIndex(conn, PgIndex.version) - history.createIndex(conn, PgIndex.gbn_idx) - for (index in defaultPgIndices) history.createIndex(conn, index) - if (optionalIndices != null) for (idx in optionalIndices) if (idx != null) createOptionalIndex(history, idx) - } - invalidateCollection(collection) - } - - /** - * Refresh the cached information of this collection, mainly updates the history tables. - * - Throws [NakshaError.COLLECTION_NOT_FOUND], if the collection has been deleted. - * @param conn the connection to query the database; if _null_, a new data connection is acquired, used, and released. - * @since 3.0.0 - */ - private fun refreshPgCollection(conn: PgConnection, collection: PgCollection): PgCollection { - // TODO: Fix me! - val cursor = PgRelation.select(conn, collection.map.id, id) - cursor.use { - // - // NOTE: We ignore all unknown relations, that allows users to add some own indices and relations! - // - var headRelation: PgRelation? = null - val headIndices: MutableList = mutableListOf() - val headPartitions: MutableMap = mutableMapOf() - val headYears: MutableMap = mutableMapOf() - var historyRelation: PgRelation? = null - val historyIndices: MutableList = mutableListOf() - val historyYears: MutableMap = mutableMapOf() - val historyPartitions: MutableMap = mutableMapOf() - var metaRelation: PgRelation? = null - val metaIndices: MutableList = mutableListOf() - while (cursor.next()) { - val rel = PgRelation(cursor) - if (rel.isAnyHeadRelation()) { - if (rel.isHeadRootRelation()) { - headRelation = rel - } else if (rel.isTable()) { - val i = rel.partitionNumber() - if (i >= 0) headPartitions[i] = rel - } else if (rel.isIndex()) { - val index = PgIndex.of(rel.name) - if (index != null && index !in headIndices) headIndices.add(index) - } - } - if (rel.isAnyHistoryRelation()) { - if (rel.isHistoryRootRelation()) { - historyRelation = rel - } else if (rel.isHistoryYearRelation()) { - val year = rel.year() - if (year > 0) historyYears[year] = rel - } else if (rel.isHistoryPartition()) { - val i = rel.partitionNumber() - if (i >= 0) historyPartitions[i] = rel - } else if (rel.isIndex()) { - val index = PgIndex.of(rel.name) - if (index != null && index !in historyIndices) historyIndices.add(index) - } - } - if (rel.isAnyMetaRelation()) { - if (rel.isMetaRootRelation()) { - metaRelation = rel - } else if (rel.isIndex()) { - val index = PgIndex.of(rel.name) - if (index != null && index !in metaIndices) metaIndices.add(index) - } - } - } - - if (headRelation != null) { - if (headRelation.isPartition()) { - val parts = headPartitions.size - if (parts < 2 || parts > 256) { - throw NakshaException( - ILLEGAL_STATE, - "Invalid amount of HEAD partitions found, must be 2..256, but is ${headPartitions.size}" - ) - } - collection.headTable = PgHead(collection, headRelation.storageClass, parts) - } else { - collection.headTable = PgHead(collection, headRelation.storageClass, 0) - } - for (index in headIndices) collection.headTable.addIndex(index) - } - if (historyRelation != null) { - val history = PgHistory(collection.headTable) - collection.historyTable = history - for (entry in historyYears) history.years[entry.key] = PgHistoryYear(history, entry.key) - } - if (metaRelation != null) { - val meta = PgMeta(collection.headTable) - collection.metaTable = meta - for (index in metaIndices) meta.addIndex(index) - } - } - return collection - } - - /** - * Deletes a collection. - * @param conn the connection to use to access the database. - * @param collection the collection to delete. - * @since 3.0.0 - */ - open fun deletePgCollection(conn: PgConnection, collection: PgCollection) { - setSearchPath(conn) - val builder = StringBuilder() - val head = collection.headTable - builder.append("DROP TABLE IF EXISTS ${head.quotedName} CASCADE;\n") - val meta = collection.metaTable - if (meta != null) builder.append("DROP TABLE IF EXISTS ${meta.quotedName} CASCADE;\n") - val history = collection.historyTable - if (history != null) builder.append("DROP TABLE IF EXISTS ${history.quotedName} CASCADE;\n") - val SQL = builder.toString() - logger.info("Dropped collection {}@{}", collection.id, collection.number) - conn.execute(SQL).close() - invalidateCollection(collection) - } - - /** - * Returns the existing collection with the given identifier; if any. - * @param conn the connection to use to access the database. - * @param id the collection-id to query. - * @return the collection, if it exists; _null_ otherwise. - * @since 3.0.0 - */ - fun getPgCollectionById(conn: PgConnection?, id: String): PgCollection? { - if (this is PgAdminMap) { - return when (id) { - COLLECTIONS_COL -> collections - TRANSACTIONS_COL -> transactions - CATALOGS_COL -> catalogs - BOOKS_COL -> books - else -> null - } - } - if (id == COLLECTIONS_COL) return collections - val number = collectionNumberById[id] - val existing = if (number != null) collectionCache[number] else null - if (existing != null || conn == null) return existing - - // Read from database - val outRows = PgColumnRows() - .withStorageNumber(storage.number) - .withMapNumber(this.number) - .withCollectionNumber(COLLECTIONS_COL_NUMBER) - .withDefaultDataEncoding(Naksha.DEFAULT_DATA_ENCODING) - .addColumns(headColumns) - setSearchPath(conn) - val SQL = """SELECT ${outRows.names()} -FROM ${collections.headTable.quotedName} -WHERE id = $1 AND (version & 3) < 2""" - val plan = conn.prepare(SQL, arrayOf(PgType.STRING.text)) - plan.execute(arrayOf(id)).fetch().use { - outRows.addAll(cursor = it) - } - if (outRows.size == 0) return null - val tuple = outRows[0] ?: return null - Naksha.cache.store(tuple) - val nakshaCollection = Naksha.decodeTuple(tuple).proxy(NakshaCollection::class) - val pgCollection = PgCollection(this, nakshaCollection) - storeCollection(pgCollection) - return pgCollection - } - - /** - * Returns the existing collection with the given number; if any. - * @param conn the connection to use to access the database. - * @param number the collection-number to query. - * @return the collection, if it exists; _null_ otherwise. - * @since 3.0.0 - */ - fun getPgCollectionByNumber(conn: PgConnection?, number: Int): PgCollection? { - if (this is PgAdminMap) { - return when (number) { - COLLECTIONS_COL_NUMBER -> collections - TRANSACTIONS_COL_NUMBER -> transactions - CATALOGS_COL_NUMBER -> catalogs - BOOKS_COL_NUMBER -> books - else -> null - } - } - if (number == COLLECTIONS_COL_NUMBER) return collections - val existing = collectionCache[number] - if (existing != null || conn == null) return existing - - // Read from database - val outRows = PgColumnRows() - .withStorageNumber(storage.number) - .withMapNumber(this.number) - .withCollectionNumber(COLLECTIONS_COL_NUMBER) - .withDefaultDataEncoding(Naksha.DEFAULT_DATA_ENCODING) - .addColumns(headColumns) - setSearchPath(conn) - val SQL = """SELECT ${outRows.names()} -FROM ${collections.headTable.quotedName} -WHERE fn = $1 AND (version & 3) < 2""" - val plan = conn.prepare(SQL, arrayOf(PgType.INT64.text)) - plan.execute(arrayOf(number)).fetch().use { - outRows.addAll(cursor = it) - } - if (outRows.size == 0) return null - val tuple = outRows[0] ?: return null - Naksha.cache.store(tuple) - val nakshaCollection = Naksha.decodeTuple(tuple).proxy(NakshaCollection::class) - val pgCollection = PgCollection(this, nakshaCollection) - storeCollection(pgCollection) - return pgCollection - } - - /** - * Returns a list of all existing collections in the map, excluding the collections' collection. - * @param conn the connection to use to access the database. - * @param map the map in which to search for the collection. - * @return the list of existing collections, _(empty, when no collections exist)_. - * @since 3.0.0 - */ - fun listPgCollections(conn: PgConnection, map: PgMap): PgCollectionList { - val list = PgCollectionList() - // TODO: Implement me! - return list - } -} diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMapList.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMapList.kt index 363f172c57..f1048dc21b 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMapList.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMapList.kt @@ -7,15 +7,15 @@ import kotlin.js.ExperimentalJsExport import kotlin.js.JsExport /** - * A list of [maps][PgMap]. + * A list of [maps][PgCatalog]. * @since 3.0.0 */ @JsExport -class PgMapList : ListProxy(PgMap::class) { +class PgMapList : ListProxy(PgCatalog::class) { /** * Add all given maps */ - fun withAll(maps: List): PgMapList { + fun withAll(maps: List): PgMapList { addAll(maps) return this } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMemberHelper.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMemberHelper.kt new file mode 100644 index 0000000000..c7138f74fe --- /dev/null +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMemberHelper.kt @@ -0,0 +1,323 @@ +@file:Suppress("OPT_IN_USAGE") + +package naksha.psql + +import naksha.base.AnyList +import naksha.base.AnyObject +import naksha.base.Int64 +import naksha.base.ListProxy +import naksha.base.PlatformList +import naksha.base.Platform.PlatformCompanion.logger +import naksha.base.Platform.PlatformCompanion.toJSON +import naksha.model.TagList +import naksha.model.objects.Member +import naksha.model.objects.MemberType +import naksha.model.objects.NakshaFeature + +// TODO: This is AI generated slop, we need to review and only use what we really need! +// What is really used should go into static Member methods! + +/** + * Helpers to map [CustomMember] values from a [NakshaFeature] into a [PgRows] row. + * + * - [walkFeature]: descend a [NakshaFeature] using the member's path; returns _null_ if the path is missing. + * - [coerce]: coerce a raw value to the type of the member; returns _null_ and logs a warning on mismatch. + * - [pgTypeFor]: maps a [CustomMemberType] to the [PgType] used for prepared-statement binding. + * - [pgSqlTypeFor]: returns the PostgreSQL DDL type for `CREATE TABLE` / `ALTER TABLE ADD COLUMN`. + * - [pgColumnName]: returns the physical column name (same as [CustomMember.name]) used in Postgres. + */ +class PgMemberHelper private constructor() { + + companion object PgMemberHelper_C { + /** + * Returns the physical Postgres column name for the given member name. + * The name is used as-is; collision with built-in columns is prevented by [validateMemberNames]. + */ + fun pgColumnName(memberName: String): String = memberName + + fun pgTypeFor(type: MemberType): PgType = when (type) { + MemberType.BOOLEAN -> PgType.BOOLEAN + MemberType.INT8 -> PgType.SHORT + MemberType.INT16 -> PgType.SHORT + MemberType.INT32 -> PgType.INT + MemberType.INT64 -> PgType.INT64 + MemberType.FLOAT32 -> PgType.FLOAT + MemberType.FLOAT64 -> PgType.DOUBLE + MemberType.STRING -> PgType.STRING + MemberType.BYTE_ARRAY -> PgType.BYTE_ARRAY + MemberType.SPATIAL -> PgType.BYTE_ARRAY + MemberType.TAG_MAP -> PgType.JSONB + MemberType.TAG_MAP_FROM_ARRAY -> PgType.JSONB + MemberType.TAG_LIST -> PgType.JSONB + else -> PgType.STRING + } + + /** + * Returns the PostgreSQL DDL type string for the given member type, used inside `CREATE TABLE` / `ALTER TABLE ADD COLUMN`. + * Note: there is no 1-byte signed integer type in PostgreSQL, so [MemberType.INT8] is materialized as `smallint`; + * the storage enforces the 8-bit range on coercion. + * [MemberType.TAG_MAP], [MemberType.TAG_MAP_FROM_ARRAY], and [MemberType.TAG_LIST] all use `jsonb STORAGE MAIN` — + * compressed inline, only TOASTed as a last resort. The only difference is the JSON shape: + * TAGS and TAGS_FROM_ARRAY persist a JSON object, SET persists a JSON array. + */ + fun pgSqlTypeFor(type: MemberType): String = when (type) { + MemberType.BOOLEAN -> "boolean" + MemberType.INT8 -> "smallint" + MemberType.INT16 -> "smallint" + MemberType.INT32 -> "integer" + MemberType.INT64 -> "bigint" + MemberType.FLOAT32 -> "real" + MemberType.FLOAT64 -> "double precision" + MemberType.STRING -> "text COLLATE \"C\"" + MemberType.BYTE_ARRAY -> "bytea" + MemberType.SPATIAL -> "bytea STORAGE EXTERNAL" + MemberType.TAG_MAP -> "jsonb STORAGE MAIN" + MemberType.TAG_MAP_FROM_ARRAY -> "jsonb STORAGE MAIN" + MemberType.TAG_LIST -> "jsonb STORAGE MAIN" + else -> "text" + } + + /** + * Returns the SQL fragment for the [Member.name] / [Member.dataType] used in `CREATE TABLE`. Example: `"age" smallint`. + */ + fun sqlDefinitionFor(member: Member): String = + "\"${pgColumnName(member.name)}\" ${pgSqlTypeFor(member.dataType)}" + + fun walkFeature(feature: NakshaFeature, path: List): Any? { + var current: Any? = feature + for (segment in path) { + if (current == null) return null + current = when (current) { + is AnyObject -> current.getRaw(segment) + else -> return null + } + } + return current + } + + fun coerce(value: Any?, type: MemberType, featureId: String, memberName: String): Any? { + if (value == null) return null + return when (type) { + MemberType.BOOLEAN -> coerceBoolean(value, featureId, memberName) + MemberType.INT8 -> coerceInt8(value, featureId, memberName) + MemberType.INT16 -> coerceInt16(value, featureId, memberName) + MemberType.INT32 -> coerceInt32(value, featureId, memberName) + MemberType.INT64 -> coerceInt64(value, featureId, memberName) + MemberType.FLOAT32 -> coerceFloat32(value, featureId, memberName) + MemberType.FLOAT64 -> coerceFloat64(value, featureId, memberName) + MemberType.STRING -> coerceString(value, featureId, memberName) + MemberType.BYTE_ARRAY -> coerceByteArray(value, featureId, memberName) + MemberType.SPATIAL -> coerceByteArray(value, featureId, memberName) + MemberType.TAG_MAP -> coerceTags(value, featureId, memberName) + MemberType.TAG_MAP_FROM_ARRAY -> coerceTagsFromArray(value, featureId, memberName) + MemberType.TAG_LIST -> coerceTagList(value, featureId, memberName) + else -> { + warnMismatch(featureId, memberName, type.toString(), value) + null + } + } + } + + private fun coerceBoolean(value: Any, featureId: String, memberName: String): Boolean? = when (value) { + is Boolean -> value + else -> { + warnMismatch(featureId, memberName, "boolean", value); null + } + } + + private fun coerceInt8(value: Any, featureId: String, memberName: String): Short? { + val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int8", value) } + if (asLong !in Byte.MIN_VALUE.toLong()..Byte.MAX_VALUE.toLong()) { + warnMismatch(featureId, memberName, "int8 (out of range)", value) + return null + } + return asLong.toShort() + } + + private fun coerceInt16(value: Any, featureId: String, memberName: String): Short? { + val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int16", value) } + if (asLong !in Short.MIN_VALUE.toLong()..Short.MAX_VALUE.toLong()) { + warnMismatch(featureId, memberName, "int16 (out of range)", value) + return null + } + return asLong.toShort() + } + + private fun coerceInt32(value: Any, featureId: String, memberName: String): Int? { + val asLong = numberToLongOrNull(value) ?: return null.also { warnMismatch(featureId, memberName, "int32", value) } + if (asLong !in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + warnMismatch(featureId, memberName, "int32 (out of range)", value) + return null + } + return asLong.toInt() + } + + private fun coerceInt64(value: Any, featureId: String, memberName: String): Int64? = when (value) { + is Int64 -> value + is Int -> Int64(value.toLong()) + is Long -> Int64(value) + is Short -> Int64(value.toLong()) + is Byte -> Int64(value.toLong()) + is Double -> if (value.isFinite() && value == value.toLong().toDouble()) Int64(value.toLong()) else { + warnMismatch(featureId, memberName, "int64", value); null + } + + is Float -> if (value.isFinite() && value == value.toLong().toFloat()) Int64(value.toLong()) else { + warnMismatch(featureId, memberName, "int64", value); null + } + + else -> { + warnMismatch(featureId, memberName, "int64", value); null + } + } + + private fun coerceFloat32(value: Any, featureId: String, memberName: String): Float? = when (value) { + is Float -> value + is Double -> value.toFloat() + is Int -> value.toFloat() + is Long -> value.toFloat() + is Int64 -> value.toLong().toFloat() + is Short -> value.toFloat() + is Byte -> value.toFloat() + else -> { + warnMismatch(featureId, memberName, "float32", value); null + } + } + + private fun coerceFloat64(value: Any, featureId: String, memberName: String): Double? = when (value) { + is Double -> value + is Float -> value.toDouble() + is Int -> value.toDouble() + is Long -> value.toDouble() + is Int64 -> value.toLong().toDouble() + is Short -> value.toDouble() + is Byte -> value.toDouble() + else -> { + warnMismatch(featureId, memberName, "float64", value); null + } + } + + private fun coerceString(value: Any, featureId: String, memberName: String): String? = when (value) { + is String -> value + else -> { + warnMismatch(featureId, memberName, "string", value); null + } + } + + private fun coerceByteArray(value: Any, featureId: String, memberName: String): ByteArray? = when (value) { + is ByteArray -> value + else -> { + warnMismatch(featureId, memberName, "byte_array", value); null + } + } + + private fun coerceTags(value: Any, featureId: String, memberName: String): String? { + if (value !is AnyObject) { + warnMismatch(featureId, memberName, "tags", value) + return null + } + return try { + toJSON(value) + } catch (_: Exception) { + warnMismatch(featureId, memberName, "tags", value); null + } + } + + private fun coerceTagsFromArray(value: Any, featureId: String, memberName: String): String? { + val tagList = when (value) { + is TagList -> value + is AnyObject -> value.proxy(TagList::class) + else -> { + warnMismatch(featureId, memberName, "tags_from_array", value); return null + } + } + val tagMap = tagList.toTagMap() + return try { + toJSON(tagMap) + } catch (_: Exception) { + warnMismatch(featureId, memberName, "tags_from_array", value); null + } + } + + /** + * Coerces a [MemberType.TAG_LIST] value: a JSON array of unique primitives (booleans, numbers, strings). + * The array is persisted unmodified (element order preserved). Entries that are `null`, non-primitive, + * or duplicates violate the set contract; the value is then not materialized (warning + NULL column). + */ + private fun coerceTagList(value: Any, featureId: String, memberName: String): String? { + val list: ListProxy<*> = when (value) { + is ListProxy<*> -> value + is PlatformList -> value.proxy(AnyList::class) + else -> { + warnMismatch(featureId, memberName, "set", value); return null + } + } + val seen = HashSet() + for (e in list) { + if (e == null) { + warnMismatch(featureId, memberName, "set (entries must not be null)", "null") + return null + } + if (!isPrimitive(e)) { + warnMismatch(featureId, memberName, "set (entries must be primitives)", e) + return null + } + if (!seen.add(e)) { + warnMismatch(featureId, memberName, "set (entries must be unique)", e) + return null + } + } + return try { + toJSON(list) + } catch (_: Exception) { + warnMismatch(featureId, memberName, "set", list); null + } + } + + private fun isPrimitive(value: Any): Boolean = when (value) { + is String, is Boolean, is Byte, is Short, is Int, is Long, is Int64, is Float, is Double -> true + else -> false + } + + private fun numberToLongOrNull(value: Any): Long? = when (value) { + is Byte -> value.toLong() + is Short -> value.toLong() + is Int -> value.toLong() + is Long -> value + is Int64 -> value.toLong() + is Float -> if (value.isFinite() && value == value.toLong().toFloat()) value.toLong() else null + is Double -> if (value.isFinite() && value == value.toLong().toDouble()) value.toLong() else null + else -> null + } + + /** + * Returns the sort priority for a [MemberType] based on PostgreSQL alignment size, to minimise + * tuple padding when columns are laid out in declaration order: + * 1. 8-byte types ([INT64], [FLOAT64]) — first, to get 8-byte alignment right away + * 2. 4-byte types ([INT32], [FLOAT32]) — next, still fixed-width + * 3. 1/2-byte types ([INT16], [INT8], [BOOLEAN]) — small fixed-width + * 4. Variable-length text ([STRING]) — variable but human-readable + * 5. Opaque variable-length ([BYTE_ARRAY], [SPATIAL], [TAGS], [TAGS_FROM_ARRAY], [SET]) — last + */ + fun columnSortOrder(type: MemberType): Int = when (type) { + MemberType.INT64 -> 0 + MemberType.FLOAT64 -> 1 + MemberType.INT32 -> 2 + MemberType.FLOAT32 -> 3 + MemberType.INT16 -> 4 + MemberType.INT8 -> 5 + MemberType.BOOLEAN -> 6 + MemberType.STRING -> 7 + MemberType.BYTE_ARRAY -> 8 + MemberType.SPATIAL -> 8 + MemberType.TAG_MAP -> 9 + MemberType.TAG_MAP_FROM_ARRAY -> 10 + MemberType.TAG_LIST -> 11 + else -> 12 + } + + private fun warnMismatch(featureId: String, memberName: String, expected: String, value: Any) { + logger.warn("Custom member '$memberName' on feature '$featureId': expected $expected, got ${value::class.simpleName}") + } + } +} diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMeta.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMeta.kt deleted file mode 100644 index 513656e64d..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgMeta.kt +++ /dev/null @@ -1,13 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.psql - -import kotlin.js.JsExport - -/** - * A META table. - * @since 3.0 - * @see [PgTable] - */ -@JsExport -class PgMeta(val head: PgHead) : PgTable(head.collection, "${head.collection.id}${PG_META}", head.storageClass, true) diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaBooks.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaBooks.kt index 3fe3c3765c..1087886aa8 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaBooks.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaBooks.kt @@ -12,9 +12,9 @@ import kotlin.js.JsExport * The internal collection in the admin-map, that keeps track of the books (global JBON2 dictionaries) of the storage. */ @JsExport -class PgNakshaBooks internal constructor(adminMap: PgAdminMap) : PgCollection(adminMap, NakshaCollection() - .withMapId(Naksha.ADMIN_MAP) - .withId(Naksha.BOOKS_COL) +class PgNakshaBooks internal constructor(adminMap: PgAdminCatalog) : PgCollection(adminMap, NakshaCollection() + .withCatalogId(Naksha.ADMIN_CATALOG_ID) + .withId(Naksha.BOOKS_COL_ID) ), PgInternalCollection, IDictManager { override fun putDictionary(dict: JbDictionary) { diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCatalogs.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCatalogs.kt index fe62e35ab9..c000290e93 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCatalogs.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCatalogs.kt @@ -11,7 +11,7 @@ import kotlin.js.JsExport * The internal collection in the admin-map, that keeps track of the catalogs (maps) of the storage. */ @JsExport -class PgNakshaCatalogs internal constructor(adminMap: PgAdminMap) : PgCollection(adminMap, NakshaCollection() - .withMapId(Naksha.ADMIN_MAP) - .withId(Naksha.CATALOGS_COL) +class PgNakshaCatalogs internal constructor(adminMap: PgAdminCatalog) : PgCollection(adminMap, NakshaCollection() + .withCatalogId(Naksha.ADMIN_CATALOG_ID) + .withId(Naksha.CATALOGS_COL_ID) ), PgInternalCollection diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCollections.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCollections.kt index a30c05eea5..747343aa01 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCollections.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaCollections.kt @@ -10,7 +10,7 @@ import kotlin.js.JsExport * The internal collection in each map that keeps track of the collections being in the map. */ @JsExport -class PgNakshaCollections internal constructor(map: PgMap) : PgCollection(map, NakshaCollection() - .withMapId(map.id) - .withId(Naksha.COLLECTIONS_COL) +class PgNakshaCollections internal constructor(map: PgCatalog) : PgCollection(map, NakshaCollection() + .withCatalogId(map.id) + .withId(Naksha.COLLECTIONS_COL_ID) ), PgInternalCollection diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaTransactions.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaTransactions.kt index d50e9d404d..c48a62de32 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaTransactions.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgNakshaTransactions.kt @@ -2,8 +2,8 @@ package naksha.psql -import naksha.model.Naksha.NakshaCompanion.ADMIN_MAP -import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL +import naksha.model.Naksha.NakshaCompanion.ADMIN_CATALOG_ID +import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL_ID import naksha.model.objects.NakshaCollection import naksha.model.objects.StandardIndices import naksha.model.objects.StandardMembers @@ -11,7 +11,7 @@ import naksha.model.objects.StoreMode import kotlin.js.JsExport /** - * The internal collection in the admin-map, that keeps track of the transactions of the storage. + * The internal collection in the admin-catalog, that keeps track of the transactions of the storage. * * This is a standard partitioned collection with: * - 16 HEAD partitions (partitioned by `fn`) @@ -27,9 +27,9 @@ import kotlin.js.JsExport * HERE global sequencer populates them. */ @JsExport -class PgNakshaTransactions internal constructor(adminMap: PgAdminMap) : PgCollection(adminMap, NakshaCollection() - .withMapId(ADMIN_MAP) - .withId(TRANSACTIONS_COL) +class PgNakshaTransactions internal constructor(adminCatalog: PgAdminCatalog) : PgCollection(adminCatalog, NakshaCollection() + .withCatalogId(ADMIN_CATALOG_ID) + .withId(TRANSACTIONS_COL_ID) .withStoreDeleted(StoreMode.OFF) .withStoreHistory(StoreMode.ON) .withStoreMeta(StoreMode.OFF) @@ -40,10 +40,11 @@ class PgNakshaTransactions internal constructor(adminMap: PgAdminMap) : PgCollec StandardMembers.GlobalVersion, ) .withIndices( - naksha.model.objects.Index().withName(PgIndex.txn_unique.name), StandardIndices.PublishNumber, StandardIndices.PublishTime, StandardIndices.GlobalVersion, ) ), PgInternalCollection +// TODO: We need to fix this, we want all internal collections to use XYZ members and indices +// For this case, we additionally want the publication members and indices. \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryBuilder.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryBuilder.kt index 875e719d2b..b49433b4ed 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryBuilder.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryBuilder.kt @@ -1,10 +1,14 @@ package naksha.psql +import naksha.base.Int64 import naksha.model.* +import naksha.model.Naksha.NakshaCompanion.HARD_TUPLE_LIMIT import naksha.model.request.* -import naksha.model.request.query.MetaColumn -import naksha.model.request.query.SortOrder.SortOrderCompanion.ASCENDING -import naksha.psql.PgColumn.PgColumnCompanion.next_version +import naksha.model.request.query.SortOrder +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.NEXT_VERSION_NAME +import naksha.psql.PgColumn.PgColumn_C.VERSION +import naksha.psql.PgColumn.PgColumn_C.VERSION_NAME import kotlin.math.max import kotlin.math.min @@ -33,193 +37,103 @@ class PgQueryBuilder(val session: PgSession, val readRequest: ReadRequest) { private fun readFeatures(req: ReadFeatures): PgQuery { // Collect needed data val pgStorage = session.storage - val mapId = req.mapId ?: throw illegalArg("mapId is missing") - val pgMap = session.getPgMapById(mapId) ?: throw mapNotFound("Map with id '$mapId' does not exist") - // We select what the client wants, maximum is always 16777216 - // Finally, the storage can limit result-size further down below 16777216 (normally we do not expect this to happen). - val REQ_LIMIT = min(max(0, req.limit ?: 16777216), session.storage.hardCap) - if (REQ_LIMIT == 0) throw illegalArg("Invalid limit given: ${req.limit}, must be 0 to 16777216") - val pgCollections: MutableList = mutableListOf() - for (collectionId in req.collectionIds) { - if (collectionId == null) continue - val pgCollection = session.getPgCollectionById(pgMap, collectionId) ?: - throw collectionNotFound("Collection with id '$collectionId' not found in map '$mapId'") - pgCollections.add(pgCollection) - } - if (pgCollections.size <= 0) throw illegalArg("Empty collection-ids in request") - val version = req.version - if (version != null && !req.queryHistory) { - throw illegalArg("Setting 'version' to '$version' requires that 'queryHistory' is enabled!") - } - val minVersion = req.minVersion - if (minVersion != null && !req.queryHistory) { - throw illegalArg("Setting 'minVersion' to '$minVersion' requires that 'queryHistory' is enabled!") - } - val versions = req.versions - if (versions != 1 && !req.queryHistory) { - throw illegalArg("Setting 'versions' to $versions requires that 'queryHistory' is enabled!") - } - if (versions < 1) { - throw illegalArg("It is not possible to request less than one version of each feature") - } - - val whereClause = PgQueryWhereBuilder(req).build() + val catalogId = req.catalogId ?: throw illegalArg("Request has not 'catalogId'") + val pgCatalog = session.getPgCatalogById(catalogId) ?: throw mapNotFound("Catalog with id '$catalogId' does not exist") + val REQ_LIMIT = min(max(0, req.limit ?: HARD_TUPLE_LIMIT), session.storage.hardCap) + if (REQ_LIMIT == 0) throw illegalArg("Invalid limit given: ${req.limit}, must be 0 to $HARD_TUPLE_LIMIT") + val collectionId: String = req.collectionId ?: throw illegalArg("Request has no 'collectionId'") + val pgCollection = session.getPgCollectionById(pgCatalog, collectionId) ?: + throw collectionNotFound("Collection with id '$collectionId' not found in catalog '$catalogId'") + + val whereClause = PgQueryWhereBuilder(req, pgCollection).build() val whereQuery = whereClause?.where ?: "" - val thePgCollection = if (pgCollections.size == 1) pgCollections[0] else null - // Columns to select, so `col_num`, `fn`, `version`, and whatever is needed for ordering. - val select_cols = mutableListOf("col_num", "fn", "version") // Column name and ordering (ASC | DESC) for customer order. - val order_by = mutableListOf>() - req.orderBy?.also { orderBy -> - // The default order is descending by tuple-number, therefore we do not need - // any special order, when the client asks for deterministic, or it asks - // for tuple-number ordering (except he asks for ascending ordering) - if (!orderBy.isDeterministic() - && !(orderBy.column == MetaColumn.tupleNumber() && orderBy.sortOrder != ASCENDING && orderBy.next == null) - ) { - val selected_names = mutableListOf() - var o: OrderBy? = orderBy - while (o != null) { - val col = o.column - if (col != null) { - // TODO: Support all columns - val col_name = col.name - val pgColumn = when (col_name) { - MetaColumn.ATTACHMENT -> PgColumn.attachment - MetaColumn.ID -> PgColumn.id - MetaColumn.VERSION -> PgColumn.version - // No single "tuple-number" column exists post-refactor; sort by `fn` - // as a pragmatic approximation of the natural primary order. - MetaColumn.TUPLE_NUMBER -> PgColumn.fn - MetaColumn.HASH -> PgColumn.hash - MetaColumn.HERE_TILE -> PgColumn.here_tile - MetaColumn.AUTHOR -> PgColumn.author - MetaColumn.AUTHOR_TS -> PgColumn.author_ts - MetaColumn.APP_ID -> PgColumn.app_id - MetaColumn.CHANGE_COUNT -> PgColumn.cc - MetaColumn.CV0 -> PgColumn.cv0 - MetaColumn.CV1 -> PgColumn.cv1 - MetaColumn.CV2 -> PgColumn.cv2 - MetaColumn.CV3 -> PgColumn.cv3 - MetaColumn.CS0 -> PgColumn.cs0 - MetaColumn.CS1 -> PgColumn.cs1 - MetaColumn.CS2 -> PgColumn.cs2 - MetaColumn.CS3 -> PgColumn.cs3 - else -> throw illegalArg("Invalid column for ordering: '$col_name'") - } - if (!selected_names.contains(col_name)) { - selected_names.add(col_name) - if (!select_cols.contains(pgColumn.name)) { - select_cols.add(pgColumn.name) - } - val SORT = if (o.sortOrder == ASCENDING) "ASC" else "DESC" - order_by.add(when (pgColumn) { - PgColumn.author_ts -> Pair("COALESCE(${PgColumn.author_ts}, ${PgColumn.updated_at})", SORT) - else -> Pair(pgColumn.name, SORT) - }) - } + var order_by: MutableList>? = null + do { // scope + var orderBy = req.orderBy + if (orderBy != null) { + order_by = mutableListOf() + if (!orderBy.isDeterministic()) { + var exit = 10 + while (orderBy != null && exit >= 0) { + val memberName = orderBy.member ?: throw illegalArg("Missing member in orderBy") + val sortOrder = orderBy.sortOrder.text + order_by.add(Pair(memberName, sortOrder)) + orderBy = orderBy.next + exit-- } - o = o.next + if (exit < 0) throw illegalArg("Too many orders in orderBy") + } else { // deterministic ordering is by `feature-number ASC, version DESC` + order_by.add(Pair(FN.toString(), SortOrder.ASCENDING.toString())) + order_by.add(Pair(VERSION.toString(), SortOrder.DESCENDING.toString())) } } - } - - // Generate query. - val selects = StringBuilder() - for (entry in pgCollections.withIndex()) { - val pgCollection = entry.value - val map = pgCollection.map - val read = PgRead(pgMap, pgCollection) - - // Note: To simplify queries, we actually always embed the collection-number internally, - // eventually, before returning the result, we decide if we put it into the header - // of the tuple-number-binary or individually into each row-identifier. - select_cols[0] = "${pgCollection.number} AS col_num" - val select_cols_string = select_cols.joinToString(", ") - - val where = if (whereQuery.isEmpty()) "" else "WHERE $whereQuery" - // HEAD has no `next_version` column — substitute with NULL so predicates referencing - // it evaluate to NULL (no HEAD rows match), matching the "no successor yet" semantics. - val whereForHeadBase = whereQuery.replace(Regex("\\b${next_version.name}\\b"), "NULL::int8") - // Filter tombstones from HEAD by default: only live rows ((version & 3) < 2). - // queryDeleted=true is the sole gate that lifts this filter and exposes tombstones. - // queryHistory=true is fully orthogonal — it adds past states from the history table - // but does NOT change tombstone visibility in HEAD. - val deletedFilter = if (!req.queryDeleted) "(version & 3) < 2" else null - val whereForHead = when { - whereForHeadBase.isEmpty() && deletedFilter != null -> "WHERE $deletedFilter" - whereForHeadBase.isEmpty() -> "" - deletedFilter != null -> "WHERE $whereForHeadBase AND $deletedFilter" - else -> "WHERE $whereForHeadBase" - } - for (head in read.headTables) { - if (selects.isNotEmpty()) selects.append(" UNION ALL\n") - selects.append("\t(SELECT $select_cols_string FROM ${map.quotedId}.${head.quotedName} $whereForHead)\n") - } - - // The shadow/deleted table has been removed. Deleted rows now live in HEAD with (version & 3) == 2. - // queryDeleted=true is handled above by omitting the tombstone filter on HEAD. - - val historyTables = read.historyTables - if (req.queryHistory && historyTables != null) { - // TODO: We need to improve, because we only want $versions variants! - // If only one version is requested, we can improve the query to only return this version! - val better_where = if (version != null && versions == 1) - (if (where.isEmpty()) "WHERE " else "$where AND ") + "$next_version > $version" - else - where - for (history in historyTables) { - if (selects.isNotEmpty()) selects.append(" UNION ALL\n") - selects.append("\t(SELECT $select_cols_string FROM ${map.quotedId}.${history.quotedName} $better_where)\n") - } + } while(false) + + // TODO: We want to use placeholders! + + val WHERE = StringBuilder() + if (!req.queryDeleted) WHERE.append("($VERSION_NAME & 3) < 2 ") + var version = req.version + if (version != null) { + version = version or Int64(3) + if (version == Version.HEAD.number) { + version = null // HEAD + } else { + if (WHERE.isNotEmpty()) WHERE.append(" AND ") + WHERE.append("($VERSION_NAME <= ").append(version).append(")") } } - // Restore original value for `col_num` selection. - select_cols[0] = "col_num" - - // The columns we need until the last final result building. - val select_cols_string = select_cols.joinToString(", ") - - // If history is queried, and only a certain amount of versions should be returned - // we need to partition the result, so that we can select have the requested amount of versions. - val part = if (req.queryHistory && versions > 1) """, query_with_v AS ( - SELECT - $select_cols_string, - ROW_NUMBER() OVER (PARTITION BY col_num, fn ORDER BY version DESC) AS v - FROM query -), part AS ( - SELECT $select_cols_string - FROM query_with_v - WHERE v <= $versions -)""" else "" + var minVersion = req.minVersion + if (minVersion != null) { + minVersion = minVersion or Int64(3) + if (WHERE.isNotEmpty()) WHERE.append(" AND ") + WHERE.append("($VERSION_NAME >= ").append(minVersion).append(")") + } + val readHistory = req.queryHistory && pgCollection.storeHistory + val versions = req.versions + if (readHistory && versions == 1 && version != null) { // Return latest version only, but involve history. + if (WHERE.isNotEmpty()) WHERE.append(" AND ") + WHERE.append("($NEXT_VERSION_NAME > ").append(version).append(" OR $NEXT_VERSION_NAME IS NULL) ") + } + val where = if (whereQuery.isEmpty()) "" else "WHERE $WHERE " + + // The query starts with select all tuple-numbers of matching tuple. + val query = if (readHistory) """query AS +( SELECT $FN, $VERSION FROM ${pgCollection.headTable.quotedName}$where + UNION ALL + SELECT $FN, $VERSION FROM ${pgCollection.historyTable.quotedName}$where )""" else """query AS +( SELECT $FN, $VERSION FROM ${pgCollection.headTable.quotedName}$where )""" + + // If history is queried, and only a certain amount of versions should be returned, + // or we want only the latest version, but no specific version target is given, then + // we need to partition the result, so that we can select the requested amount of latest versions. + val all = if (readHistory && (versions > 1 || version == null)) """, query_partitioned AS +(SELECT $FN, $VERSION, ROW_NUMBER() OVER (PARTITION BY fn ORDER BY version DESC) AS v FROM query) +, all AS +(SELECT $FN, $VERSION FROM query_partitioned WHERE v <= $versions)""" else "" // `order_by` may be empty, if no custom ordering was requested. - val order_by_string = order_by.joinToString(", ") { "${it.first} ${it.second}" } + val order_by_string = order_by?.joinToString(", ") { "${it.first} ${it.second}" } ?: "" // apply limit and order, if given - val limited = """, limited AS ( - SELECT $select_cols_string - FROM ${if (part.isNotEmpty()) "part" else "query"} - ${if (order_by_string.isNotEmpty()) "ORDER BY $order_by_string " // Explicit ordering. - else if (part.isNotEmpty()) "ORDER BY col_num DESC, fn DESC, version DESC " // If multiple versions requested, order by version. - else "" // No explicit ordering, no multiple versions, use random oder - }LIMIT $REQ_LIMIT -)""" + val limited = """, limited AS +(SELECT $FN, $VERSION FROM ${if (all.isNotEmpty()) "all" else "query"} ${if (order_by_string.isNotEmpty()) "ORDER BY $order_by_string " +else if (all.isNotEmpty()) "ORDER BY $FN ASC, $VERSION DESC " else ""}LIMIT $REQ_LIMIT)""" // The final SQL query. // We only need `col_num`, `fn`, and `version`; the additional columns in limit were only for sorting. // If we only select from a single collection (thePgCollection != null), `col_num` is implicit. - val SQL = """WITH query AS ( -$selects)$part$limited -SELECT ${if (thePgCollection == null) "col_num, fn, version" else "fn, version"} FROM limited""" + val SQL = """WITH $query$all$limited +SELECT $FN, $VERSION FROM limited""" return PgQuery( sql = SQL, argValues = whereClause?.argValues?.toTypedArray() ?: emptyArray(), argTypes = whereClause?.argTypeNames ?: emptyArray(), pgStorage.number, - pgMap.number, - thePgCollection?.number + pgCatalog.catalogNumber, + pgCollection.collectionNumber ) } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereBuilder.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereBuilder.kt index 8158d80e06..89c28cb2d3 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereBuilder.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereBuilder.kt @@ -1,169 +1,324 @@ package naksha.psql -import naksha.base.AnyList -import naksha.base.ListProxy +import naksha.base.Int64 import naksha.base.Platform.PlatformCompanion.toJSON -import naksha.geo.HereTile -import naksha.geo.SpGeometry -import naksha.model.* +import naksha.base.StringList +import naksha.model.Naksha +import naksha.model.NakshaError +import naksha.model.NakshaException +import naksha.model.illegalArg +import naksha.model.objects.StandardMembers import naksha.model.request.ReadFeatures -import naksha.model.request.query.* +import naksha.model.request.ops.* /** * Helper to convert a [ReadFeatures] request into a sql `WHERE` query. + * + * The collection need to be provided, because they potentially _(when not cached)_ need to be read from the database, therefore, they require a session. We do not want to link this code to a session _(it would break unit testing)_, so we simply expect the collections as input parameter. * @param request the request to wrap. + * @param collection the collection for which to generate the `WHERE` query. * @since 3.0 * @see [build] */ -internal class PgQueryWhereBuilder(private val request: ReadFeatures) { +internal class PgQueryWhereBuilder(private val request: ReadFeatures, private val collection: PgCollection) { - private val argValues: MutableList = mutableListOf() - private val argTypes: MutableList = mutableListOf() - private val where = StringBuilder() + val where = StringBuilder() + val argValues: MutableList = mutableListOf() + val argTypes: MutableList = mutableListOf() /** - * Convert the request into a `WHERE` query. - * @return the [PgQueryWhereClause]. + * Convert the request into `WHERE` queries. + * @return the [PgQueryWhereClause]; `null` if basically everything should be read. * @since 3.0 */ fun build(): PgQueryWhereClause? { - whereFeatureId() - whereGuids() - whereVersion() - whereMetadata() - whereSpatial() - whereRefTiles() - whereTags() - return if (where.isBlank()) { - null - } else { - PgQueryWhereClause(where = where.toString(), argValues = argValues, argTypes = argTypes) + var op: Op? = request.queryMembers + if (op == null) op = QueryConverter.convert(request.query) + if (op == null) return null + applyOp(op) + if (request.featureIds.isNotEmpty()) { // backward compatibility for feature IDs read requests + whereFeatureId() } + return PgQueryWhereClause(collection, where.toString(), argValues, argTypes) } - private fun whereFeatureId() { - val featureIds = request.featureIds.filterNotNull() - if (featureIds.isEmpty()) return - - // Partition into numeric IDs (fn >= 0, id stored as NULL in DB) and named IDs (fn < 0, id NOT NULL). - val numericFns = featureIds.mapNotNull { id -> - val fn = Naksha.featureNumber(id) - if (fn >= naksha.base.Int64(0)) fn else null - } - val namedIds = featureIds.filter { id -> Naksha.featureNumber(id) < naksha.base.Int64(0) } - - val conditions = mutableListOf() - if (namedIds.isNotEmpty()) { - val placeholder = placeholderForArg(namedIds.toTypedArray(), PgType.STRING_ARRAY) - conditions += "${PgColumn.id} = ANY($placeholder)" - } - if (numericFns.isNotEmpty()) { - val placeholder = placeholderForArg(numericFns.toTypedArray(), PgType.INT64_ARRAY) - conditions += "${PgColumn.fn} = ANY($placeholder)" - } - if (conditions.isNotEmpty()) { - if (where.isNotEmpty()) where.append(" AND ") - if (conditions.size == 1) where.append(conditions[0]) - else where.append("(${conditions.joinToString(" OR ")})") - } - } - - private fun whereGuids() { - val tupleNumbers = request.guids.mapNotNull { it?.tupleNumber } - if (tupleNumbers.isNotEmpty()) { - if (where.isNotEmpty()) where.append(" AND ") - val fns = arrayOfNulls(tupleNumbers.size) - val versions = arrayOfNulls(tupleNumbers.size) - for (i in tupleNumbers.indices) { - fns[i] = tupleNumbers[i].featureNumber - versions[i] = tupleNumbers[i].version.txn + private fun applyOp(rawOp: Op, negate: Boolean = false) { + val opName = rawOp.op + val op = Op.detect(rawOp) ?: throw illegalArg("Unknown operation: '$opName'") + when (op) { + is And -> { + if (negate) where.append(" NOT ") + val children = op.children + if (children.size > 1) where.append('(') + var first = true + for (child in children) { + if (child == null) continue + if (first) first = false else where.append(" AND ") + // TODO optimization if only TagMapHasKey + applyOp(child) + } + if (children.size > 1) where.append(") ") else where.append(" ") + return } - val fnPlaceholder = placeholderForArg(fns, PgType.INT64_ARRAY) - val versionPlaceholder = placeholderForArg(versions, PgType.INT64_ARRAY) - where.append("(${PgColumn.fn}, ${PgColumn.version}) IN (SELECT * FROM unnest($fnPlaceholder::int8[], $versionPlaceholder::int8[]))") - } - } - - private fun whereVersion() { - val txn = request.version - if (txn != null) { - if (where.isNotEmpty()) where.append(" AND ") - where.append("${PgColumn.version} <= ${txn.txn}") - } - val min_txn = request.minVersion - if (min_txn != null) { - if (where.isNotEmpty()) where.append(" AND ") - where.append("${PgColumn.version} >= ${min_txn.txn}") - } - } - - private fun whereSpatial() { - val spatialQuery = request.query.spatial - if (spatialQuery != null) { - if (where.isNotEmpty()) { - where.append(" AND (") - } else { - where.append(" (") + is Or -> { + if (negate) where.append(" NOT ") + val children = op.children + if (children.size > 1) where.append('(') + var first = true + for (child in children) { + if (child == null) continue + if (first) first = false else where.append(" OR ") + // TODO optimization if only TagMapHasKey + applyOp(child) + } + if (children.size > 1) where.append(") ") else where.append(" ") + return + } + is Not -> { + applyOp(op.child, !negate) + return } - whereNestedSpatial(spatialQuery) - where.append(")") } - } - - private fun whereNestedSpatial(spatial: ISpatialQuery) { - when (spatial) { - is SpNot -> not( - subClause = spatial.query, - subClauseResolver = this::whereNestedSpatial - ) - - is SpAnd -> and( - subClauses = spatial.filterNotNull(), - subClauseResolver = this::whereNestedSpatial - ) - - is SpOr -> or( - subClauses = spatial.filterNotNull(), - subClauseResolver = this::whereNestedSpatial - ) - - is SpIntersects -> { - val queryGeometry = nakshaGeometry(spatial.geometry) - val geometryToCompare = when (val transformation = spatial.transformation) { - null -> queryGeometry - else -> resolveTransformation(transformation, queryGeometry) + val at: String = op.at ?: throw illegalArg("Missing member name for operation $opName") + when (op) { + is IsNull -> { + if (negate) + where.append(at).append(" IS NOT NULL").append(' ') + else + where.append(at).append(" IS NULL").append(' ') + } + is IsTrue -> { + if (negate) + where.append(at).append('=').append(placeholderForArg(false, PgType.BOOLEAN)).append(' ') + else + where.append(at).append('=').append(placeholderForArg(true, PgType.BOOLEAN)).append(' ') + } + is IsFalse -> { + if (negate) + where.append(at).append('=').append(placeholderForArg(true, PgType.BOOLEAN)).append(' ') + else + where.append(at).append('=').append(placeholderForArg(false, PgType.BOOLEAN)).append(' ') + } + is Equals -> { + val value: Any? = op.value + if (value == null) { + if (negate) where.append(at).append(" IS NOT NULL ") else where.append(at).append(" IS NULL ") + } else { + if (negate) + where.append(at).append("!=").append(placeholderForArg(value)).append(' ') + else + where.append(at).append('=').append(placeholderForArg(value)).append(' ') } - where.append("ST_Intersects(naksha_2d(${PgColumn.geo}), $geometryToCompare)") } - - is SpRefInHereTile -> { - where.append(refPointInTile(spatial.getHereTile())) + is Lt -> { + if (negate) // NOT Greater Than + where.append(at).append("<=").append(placeholderForArg(op.value)).append(' ') + else // Greater Than + where.append(at).append(">").append(placeholderForArg(op.value)).append(' ') + } + is Gte -> { + if (negate) // NOT Greater Than or Equal to + where.append(at).append("<").append(placeholderForArg(op.value)).append(' ') + else // Greater Than or Equal to + where.append(at).append(">=").append(placeholderForArg(op.value)).append(' ') + } + is Lt -> { + if (negate) // NOT Less Than + where.append(at).append(">=").append(placeholderForArg(op.value)).append(' ') + else // Less Than + where.append(at).append("<").append(placeholderForArg(op.value)).append(' ') + } + is Lte -> { + if (negate) // NOT Less Than or Equal to + where.append(at).append(">").append(placeholderForArg(op.value)).append(' ') + else // Less Than or Equal to + where.append(at).append("<=").append(placeholderForArg(op.value)).append(' ') + } + is StartsWith -> { + if (negate) where.append("NOT ") + where.append("starts_with(").append(at).append(", ").append(placeholderForArg(op.value)).append(") ") + } + is IsAnyOf -> { + if (negate) where.append("NOT ") + where.append(at).append("= ANY(").append(placeholderForArg(op.items)).append(") ") + } + is TagMapHasKey -> { + if (negate) where.append("NOT ") + where.append(at).append("::jsonb").append(" ? ").append(placeholderForArg(op.key)).append(" ") + } + is TagMapHasAnyOf -> { + if (negate) where.append("NOT ") + where.append(at).append("::jsonb").append(" ?| ").append(placeholderForArg(op.keys)).append(" ") + } + is TagMapHasAllOf -> { + if (negate) where.append("NOT ") + where.append(at).append("::jsonb").append(" ?& ").append(placeholderForArg(op.keys)).append(" ") } + is TagIsNull -> { + // ( foo::jsonb ? $1 AND ((foo::jsonb)->>$1) IS [NOT ]NULL) + where.append("( ") + .append(at).append("::jsonb").append(" ? ").append(placeholderForArg(op.key)).append(" AND ") + .append("((").append(at).append("::jsonb)").append("->>").append(placeholderForArg(op.key)).append(")") + if (negate) where.append("IS NOT NULL) ") else where.append("IS NULL) ") + } + is TagEquals -> { + val pgType = PgType.ofValue(op.value) ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.value}") + val value = pgType.convertValue(op.value) + if (negate) where.append("NOT ") + // [NOT ]((foo::jsonb)->>$1)::int8 = $2 + where.append("((").append(at).append("::jsonb)") + .append("->>").append(placeholderForArg(op.key)).append("::").append(pgType).append(")") + .append("=").append(placeholderForArg(value, pgType)) + } + is TagGt -> { + val pgType = PgType.ofValue(op.value) ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.value}") + val value = pgType.convertValue(op.value) + if (negate) where.append("NOT ") + // [NOT ]((foo::jsonb)->>$1)::int8 > $2 + where.append("((").append(at).append("::jsonb)") + .append("->>").append(placeholderForArg(op.key)).append("::").append(pgType).append(")") + .append(">").append(placeholderForArg(value, pgType)) + } + is TagGte -> { + val pgType = PgType.ofValue(op.value) ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.value}") + val value = pgType.convertValue(op.value) + if (negate) where.append("NOT ") + // [NOT ]((foo::jsonb)->>$1)::int8 >= $2 + where.append("((").append(at).append("::jsonb)") + .append("->>").append(placeholderForArg(op.key)).append("::").append(pgType).append(")") + .append(">=").append(placeholderForArg(value, pgType)) + } + is TagLt -> { + val pgType = PgType.ofValue(op.value) ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.value}") + val value = pgType.convertValue(op.value) + if (negate) where.append("NOT ") + // [NOT ]((foo::jsonb)->>$1)::int8 < $2 + where.append("((").append(at).append("::jsonb)") + .append("->>").append(placeholderForArg(op.key)).append("::").append(pgType).append(")") + .append("<").append(placeholderForArg(value, pgType)) + } + is TagLte -> { + val pgType = PgType.ofValue(op.value) ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.value}") + val value = pgType.convertValue(op.value) + if (negate) where.append("NOT ") + // [NOT ]((foo::jsonb)->>$1)::int8 <= $2 + where.append("((").append(at).append("::jsonb)") + .append("->>").append(placeholderForArg(op.key)).append("::").append(pgType).append(")") + .append("<=").append(placeholderForArg(value, pgType)) + } + is TagStartsWith -> { + val pgType = PgType.ofValue(op.value) ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.value}") + val value = pgType.convertValue(op.value) + // [NOT ]starts_with(((foo::jsonb)->>$1), $2) + if (negate) where.append("NOT ") + where.append("starts_with(((").append(at).append("::jsonb)") + .append("->>").append(placeholderForArg(op.key)).append("::").append(pgType).append(")") + .append(", ").append(placeholderForArg(value, pgType)).append(") ") + } + is TagListContains -> { + val pgType = PgType.ofValue(op.item) + ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.item}") + val value = pgType.convertValue(op.item) + // [NOT ]foo::jsonb @> $1::jsonb + val placeholder = placeholderForArg(value, pgType) + if (negate) where.append("NOT ") + where.append(at).append("::jsonb @> ").append(placeholder).append("::jsonb ") + } + is TagListContainsAllOf -> { + val pgType = PgType.ofValue(op.items) + ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.items}") + val value = pgType.convertValue(op.items) + val placeholder = placeholderForArg(toJSON(value), pgType) + if (negate) where.append("NOT ") + where.append(at).append("::jsonb @> ").append(placeholder).append("::jsonb ") + } + is TagListContainsAnyOf -> { + val items = op.items.filterNotNull() + // Any-of over empty set -> false; negated -> true + if (items.isEmpty()) { + if (negate) where.append("TRUE ") else where.append("FALSE ") + return + } - else -> throw NakshaException( - NakshaError.ILLEGAL_ARGUMENT, - "Invalid spatial query found: $spatial" - ) + if (negate) where.append("NOT ") + // Multiple items: build OR of single-element containment checks + where.append('(') + val pgType = PgType.ofValue(op.items.first()) + ?: throw illegalArg("The given value is no valid argument for ${op.op}}: ${op.items.first()}") + var first = true + for (item in items) { + if (!first) where.append(" OR ") + first = false + val value = pgType.convertValue(item) + val placeholder = placeholderForArg(value, pgType) + where.append(at).append("::jsonb @> ").append(placeholder).append("::jsonb") + } + where.append(") ") + } + is Intersects -> { + val geoBytes = Naksha.encodeGeometry(op.value) + val geoBytesPlaceholder = placeholderForArg(geoBytes, PgType.BYTE_ARRAY) + val queryGeometry = "naksha_2d($geoBytesPlaceholder)" + val transformation = op.transformers + val geometryToCompare = + if (transformation.isEmpty()) queryGeometry + else resolveTransformation(transformation, queryGeometry) + where.append("ST_Intersects(naksha_2d(${StandardMembers.Geometry}), $geometryToCompare)") + } + else -> throw illegalArg("Unknown operation: '$op'") } } - private fun nakshaGeometry(geometry: SpGeometry): String { - val geoBytes = Naksha.encodeGeometry(geometry) - val geoBytesPlaceholder = placeholderForArg(geoBytes, PgType.BYTE_ARRAY) - return "naksha_2d($geoBytesPlaceholder)" + /** + * Returns a placeholder string like `$1` for the given value, and add the values and its type into the value and type arrays. + */ + private fun placeholderForArg(value: Any?): String { + // TODO: Detect the type, if not possible, throw illegalArg() + // AnyList for + ANY() + val type: PgType = PgType.STRING; + argValues.add(value) + argTypes.add(type) + return "\$${argTypes.size}" + } + + /** + * Returns a placeholder string like `$1` for the given value, and add the values and its type into the value and type arrays. + * @param value any number _(including `Int64`)_ + * @return + */ + private fun placeholderForNumber(value: Any?): String { + // TODO: Detect the type, which must be any number, otherwise throw illegalArg() + val type: PgType = PgType.STRING; + argValues.add(value) + argTypes.add(type) + return "\$${argTypes.size}" + } + + /** + * Returns a placeholder string like `$1` for the given value, and add the values and its type into the value and type arrays. + */ + private fun placeholderForArg(value: Any?, type: PgType): String { + // TODO: Ensure that the value matches the type, otherwise throw illegalArg()! + argValues.add(value) + argTypes.add(type) + return "\$${argTypes.size}" } private fun resolveTransformation( - transformation: SpTransformation, + transformationList: SpTransformationList, basicGeometry: String ): String { - return when (transformation) { - is SpBuffer -> resolveBuffer(transformation, basicGeometry) - else -> throw NakshaException( - NakshaError.UNSUPPORTED_OPERATION, - "This transformation is not yet supported: ${transformation::class.simpleName}" - ) + var geometry = basicGeometry + for (transformation in transformationList) { + geometry = when (transformation) { + is SpBuffer -> resolveBuffer(transformation, geometry) + else -> throw NakshaException( + NakshaError.UNSUPPORTED_OPERATION, + "This transformation is not yet supported: ${transformation!!::class.simpleName}" + ) + } } + return geometry } private fun resolveBuffer(buffer: SpBuffer, basicGeometry: String): String { @@ -214,324 +369,431 @@ internal class PgQueryWhereBuilder(private val request: ReadFeatures) { } } - private fun whereRefTiles() { - val hereTiles = request.query.refTiles - .filterNotNull() - .map { HereTile(it) } - if (hereTiles.isNotEmpty()) { - if (where.isNotEmpty()) { - where.append(" AND (") - } else { - where.append(" (") - } - where.append(refPointInAnyOfTiles(hereTiles)) - where.append(")") - } - } - - private fun refPointInAnyOfTiles(hereTiles: List): String { - return hereTiles.joinToString(separator = " OR ") { hereTile -> - refPointInTile(hereTile) - } - } - - private fun refPointInTile(hereTile: HereTile): String { - val lowerBoundPlaceholder = placeholderForArg( - hereTile.maxLevelLowerBound().intKey, - PgType.INT - ) - val upperBoundPlaceholder = placeholderForArg( - hereTile.maxLevelUpperBound().intKey, - PgType.INT - ) - return "(${PgColumn.here_tile} >= $lowerBoundPlaceholder AND ${PgColumn.here_tile} <= $upperBoundPlaceholder)" - } - - private fun whereMetadata() { - val metaQuery = request.query.metadata - if (metaQuery != null) { - if (where.isNotEmpty()) { - where.append(" AND (") + private fun whereFeatureId() { + // Partition into numeric IDs (fn >= 0, id stored as NULL in DB) and named IDs (fn < 0, id NOT NULL). + val reqIds: StringList = request.featureIds + val featureNumbers: MutableList = mutableListOf() + val featureIds: MutableList = mutableListOf() + for (id in reqIds) { + if (id == null) continue + val fn = Naksha.featureNumber(id) + if (fn >= Int64(0)) { + featureNumbers.add(fn) } else { - where.append(" (") + featureIds.add(id) } - whereNestedMetadata(metaQuery) - where.append(")") } - } - - private fun whereNestedMetadata(metaQuery: IMetaQuery) { - when (metaQuery) { - is MetaNot -> not( - subClause = metaQuery.query, - subClauseResolver = this::whereNestedMetadata - ) - - is MetaAnd -> and( - subClauses = metaQuery.filterNotNull(), - subClauseResolver = this::whereNestedMetadata - ) + if (featureNumbers.isEmpty() && featureIds.isEmpty()) return - is MetaOr -> or( - subClauses = metaQuery.filterNotNull(), - subClauseResolver = this::whereNestedMetadata - ) + // For each collection: + if (where.isNotEmpty()) where.append(" AND ") - is MetaQuery -> { - val isActionQuery = metaQuery.column == MetaColumn.action() - val pgColumn = - if (isActionQuery) { - PgColumn.version - } else { - PgColumn.ofRowColumn(metaQuery.column) ?: throw NakshaException( - NakshaError.ILLEGAL_STATE, - "Couldn't find PgColumn for TupleColumn: ${metaQuery.column.name}" - ) - } - val leftOperand = if (isActionQuery) { - "(${PgColumn.version.name} & 3)::int4" - } else if (pgColumn == PgColumn.created_at || pgColumn == PgColumn.author_ts) { - "COALESCE(${pgColumn.name}, ${PgColumn.updated_at.name})" - } else { - pgColumn.name - } - // Action lives in the lower 2 bits of `version`; the comparison value is a small int. - val placeholderType = if (isActionQuery) PgType.INT else pgColumn.type - val resolvedQuery = when (val op = metaQuery.op) { - is StringOp -> { - val placeholder = placeholderForArg(metaQuery.value, placeholderType) - resolveStringOp(op, leftOperand, placeholder) - } - is DoubleOp -> { - val placeholder = placeholderForArg(metaQuery.value, placeholderType) - resolveDoubleOp(op, leftOperand, placeholder) - } - AnyOp.IS_ANY_OF -> { - val placeholder = placeholderForArg(metaQuery.value, arrayTypeFor(placeholderType)) - "$leftOperand = ANY($placeholder)" - } - else -> throw illegalArg("Unknown op type: ${op::class.simpleName}") - } - where.append(resolvedQuery) - } - - else -> throw NakshaException( - NakshaError.ILLEGAL_ARGUMENT, - "Unknown metadata query type: ${metaQuery::class.simpleName}" - ) - } - } - - private fun arrayTypeFor(pgType: PgType): PgType { - return when (pgType) { - PgType.BOOLEAN -> PgType.BOOLEAN_ARRAY - PgType.SHORT -> PgType.SHORT_ARRAY - PgType.INT -> PgType.INT_ARRAY - PgType.INT64 -> PgType.INT64_ARRAY - PgType.FLOAT -> PgType.FLOAT_ARRAY - PgType.DOUBLE -> PgType.DOUBLE_ARRAY - PgType.STRING -> PgType.STRING_ARRAY - PgType.BYTE_ARRAY -> PgType.BYTE_ARRAY_ARRAY - else -> throw illegalArg("Unknown array type for PgType: ${pgType::class.simpleName}") + where.append("( ") + if (featureIds.isNotEmpty()) { + val op = IsAnyOf(at = StandardMembers.Id, items = featureIds.toTypedArray()) + applyOp(op) } - } + if (featureNumbers.isNotEmpty()) { + if (featureIds.isNotEmpty()) where.append(" OR ") - private fun whereTags() { - val tagQuery = request.query.tags - if (tagQuery != null) { - if (where.isNotEmpty()) { - where.append(" AND (") - } else { - where.append(" (") - } - whereNestedTags(tagQuery) - where.append(")") + val op = IsAnyOf(at = StandardMembers.FeatureNumber, items = featureNumbers.toTypedArray()) + applyOp(op) } + where.append(")") } - private fun whereNestedTags(tagQuery: ITagQuery) { - when (tagQuery) { - is TagSetContains -> resolveTagSetContains(tagQuery) - is TagNot -> not(tagQuery.query, this::whereNestedTags) - is TagOr -> { - if(containsOnlyTagExists(tagQuery)){ - // for tags without values we can utilize top-level-key based '?|' operand - // https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-JSONB-OP-TABLE - val tagNames = tagQuery.filterIsInstance().map { it.name } - resolveTagNamesArrayOperation( - jsonbOperator = "?|", // 'jsonb_exists_any' is equivalent but will not hit the GIN index - tagNames = tagNames - ) - } else { - or(tagQuery.filterNotNull(), this::whereNestedTags) - } - } - is TagAnd -> { - if(containsOnlyTagExists(tagQuery)){ - // for tags without values we can utilize top-level-key based '?&' operand - // https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-JSONB-OP-TABLE - val tagNames = tagQuery.filterIsInstance().map { it.name } - resolveTagNamesArrayOperation( - jsonbOperator = "?&", // 'jsonb_exists_all' is equivalent but MIGHT not hit the GIN index - tagNames = tagNames - ) - } else { - and(tagQuery.filterNotNull(), this::whereNestedTags) - } - } - is TagQuery -> resolveSingleTagQuery(tagQuery) - } - } - - private fun containsOnlyTagExists(container: ListProxy): Boolean = - container.all { it == null || it is TagExists } - - /** - * Element containment on a set-form tags column (jsonb array): `tags @> '[]'::jsonb`. - * The `@>` operator matches the element in its type (string, boolean, number) and is supported - * by the GIN index over the column. - */ - private fun resolveTagSetContains(tagQuery: TagSetContains) { - val element = AnyList() - element.add(tagQuery.element) - val placeholder = placeholderForArg(toJSON(element), PgType.STRING) - where.append("$tagsAsJsonb @> $placeholder::jsonb") - } - - private fun resolveTagNamesArrayOperation(jsonbOperator: String, tagNames: List) { - val tagKeysArray = tagNames.toTypedArray() - val tagKeysPlaceholder = placeholderForArg(tagKeysArray, PgType.STRING_ARRAY) - where.append("$tagsAsJsonb ?$jsonbOperator $tagKeysPlaceholder") - } - - private fun resolveSingleTagQuery(tagQuery: TagQuery) { - when (tagQuery) { - is TagExists -> { - val tagNamePlaceholder = placeholderForArg(tagQuery.name, PgType.STRING) - where.append("$tagsAsJsonb ?? $tagNamePlaceholder") - } - - is TagValueIsNull -> { - val tagValuePlaceholder = placeholderForArg(selectTagValue(tagQuery), PgType.STRING) - where.append("$tagValuePlaceholder IS NULL") - } - - is TagValueIsBool -> { - if (tagQuery.value) { - where.append(selectTagValue(tagQuery, PgType.BOOLEAN)) - } else { - where.append("not(${selectTagValue(tagQuery, PgType.BOOLEAN)})") - } - } - - is TagValueIsDouble -> { - val queryValuePlaceholder = placeholderForArg(tagQuery.value, PgType.DOUBLE) - val doubleOp = resolveDoubleOp( - tagQuery.op, - selectTagValue(tagQuery, PgType.DOUBLE), - queryValuePlaceholder - ) - where.append(doubleOp) - } - - is TagValueIsString -> { - val queryValuePlaceholder = placeholderForArg(tagQuery.value, PgType.STRING) - val stringEquals = resolveStringOp( - StringOp.EQUALS, - selectTagValue(tagQuery, PgType.STRING), - queryValuePlaceholder - ) - where.append(stringEquals) - } - - is TagValueMatches -> { - val jsonPathPlaceholder = placeholderForArg( - "\$.${tagQuery.name} ? (@ like_regex \"${tagQuery.regex}\")", - PgType.STRING - ) - where.append("$tagsAsJsonb @?? $jsonPathPlaceholder::jsonpath") - } - } - } - - private fun selectTagValue(tagQuery: TagQuery, castTo: PgType? = null): String { - val tagKeyPlaceholder = placeholderForArg(tagQuery.name, PgType.STRING) - return when (castTo) { - null -> "$tagsAsJsonb->$tagKeyPlaceholder" - PgType.STRING -> "$tagsAsJsonb->>$tagKeyPlaceholder" - else -> "($tagsAsJsonb->$tagKeyPlaceholder)::${castTo.value}" - } - } - - private fun not(subClause: T, subClauseResolver: (T) -> Unit) { - where.append(" NOT (") - subClauseResolver(subClause) - where.append(") ") - } - - private fun and(subClauses: List, subClauseResolver: (T) -> Unit) = - multiClause("AND", subClauses, subClauseResolver) - - private fun or(subClauses: List, subClauseResolver: (T) -> Unit) = - multiClause("OR", subClauses, subClauseResolver) - - private fun multiClause( - operand: String, - subClauses: List, - subClauseResolver: (T) -> Unit - ) { - where.append(" (") - subClauses.forEachIndexed { index, subClause -> - if (index > 0) { - where.append(" $operand ") - } - subClauseResolver(subClause) - } - where.append(") ") - } - - private fun placeholderForArg(value: Any?, type: PgType): String { - argValues.add(value) - argTypes.add(type) - return "\$${argTypes.size}" - } - - private fun resolveStringOp( - stringOp: StringOp, - leftOperand: String, - rightOperand: String - ): String { - return when (stringOp) { - StringOp.EQUALS -> "$leftOperand = $rightOperand" - StringOp.NOT_EQUALS -> "$leftOperand != $rightOperand" - StringOp.STARTS_WITH -> "starts_with($leftOperand, $rightOperand)" - else -> throw NakshaException( - NakshaError.ILLEGAL_ARGUMENT, - "Unknown StringOp: $stringOp" - ) - } - } - - private fun resolveDoubleOp( - doubleOp: DoubleOp, - leftOperand: String, - rightOperand: String - ): String { - return when (doubleOp) { - DoubleOp.EQ -> "$leftOperand = $rightOperand" - DoubleOp.NE -> "$leftOperand != $rightOperand" - DoubleOp.GT -> "$leftOperand > $rightOperand" - DoubleOp.GTE -> "$leftOperand >= $rightOperand" - DoubleOp.LT -> "$leftOperand < $rightOperand" - DoubleOp.LTE -> "$leftOperand <= $rightOperand" - else -> throw NakshaException( - NakshaError.ILLEGAL_ARGUMENT, - "Unknown DoubleOp: $doubleOp" - ) - } - } - - companion object { - private val tagsAsJsonb = PgColumn.tags.name - } + // --------------------------------------------------------< OLD CODE >------------------------------------------------------------- +// +// private fun whereGuids() { +// val tupleNumbers = request.guids.mapNotNull { it?.tupleNumber } +// if (tupleNumbers.isNotEmpty()) { +// if (where.isNotEmpty()) where.append(" AND ") +// +// val fns = arrayOfNulls(tupleNumbers.size) +// val versions = arrayOfNulls(tupleNumbers.size) +// for (i in tupleNumbers.indices) { +// fns[i] = tupleNumbers[i].featureNumber +// versions[i] = tupleNumbers[i].version +// } +// val featureNumbersArg = placeholderForArg(fns, PgType.INT64_ARRAY) +// val versionsArg = placeholderForArg(versions, PgType.INT64_ARRAY) +// where.append("($FN, $VERSION) IN (SELECT * FROM unnest($featureNumbersArg::int8[], $versionsArg::int8[]))") +// } +// } +// +// private fun whereVersion() { +// val version = request.version +// if (version != null) { +// if (where.isNotEmpty()) where.append(" AND ") +// where.append("$VERSION <= ${version.toInt()}") +// } +// val minVersion = request.minVersion +// if (minVersion != null) { +// if (where.isNotEmpty()) where.append(" AND ") +// where.append("$VERSION >= ${minVersion.toInt()}") +// } +// } +// +// private fun whereSpatial() { +// val spatialQuery = request.query.spatial +// if (spatialQuery != null) { +// if (where.isNotEmpty()) { +// where.append(" AND (") +// } else { +// where.append(" (") +// } +// whereNestedSpatial(spatialQuery) +// where.append(")") +// } +// } +// +// private fun whereNestedSpatial(spatial: ISpatialQuery) { +// when (spatial) { +// is SpNot -> not( +// subClause = spatial.query, +// subClauseResolver = this::whereNestedSpatial +// ) +// +// is SpAnd -> and( +// subClauses = spatial.filterNotNull(), +// subClauseResolver = this::whereNestedSpatial +// ) +// +// is SpOr -> or( +// subClauses = spatial.filterNotNull(), +// subClauseResolver = this::whereNestedSpatial +// ) +// +// is SpIntersects -> { +// val queryGeometry = nakshaGeometry(spatial.geometry) +// val geometryToCompare = when (val transformation = spatial.transformation) { +// null -> queryGeometry +// else -> resolveTransformation(transformation, queryGeometry) +// } +// where.append("ST_Intersects(naksha_2d(${StandardMembers.Geometry}), $geometryToCompare)") +// } +// +// is SpRefInHereTile -> { +// where.append(refPointInTile(spatial.getHereTile())) +// } +// +// else -> throw NakshaException( +// NakshaError.ILLEGAL_ARGUMENT, +// "Invalid spatial query found: $spatial" +// ) +// } +// } +// +// +// private fun whereRefTiles() { +// val hereTiles = request.query.refTiles +// .filterNotNull() +// .map { HereTile(it) } +// if (hereTiles.isNotEmpty()) { +// if (where.isNotEmpty()) { +// where.append(" AND (") +// } else { +// where.append(" (") +// } +// where.append(refPointInAnyOfTiles(hereTiles)) +// where.append(")") +// } +// } +// +// private fun refPointInAnyOfTiles(hereTiles: List): String { +// return hereTiles.joinToString(separator = " OR ") { hereTile -> +// refPointInTile(hereTile) +// } +// } +// +// private fun refPointInTile(hereTile: HereTile): String { +// val lowerBoundPlaceholder = placeholderForArg( +// hereTile.maxLevelLowerBound().intKey, +// PgType.INT +// ) +// val upperBoundPlaceholder = placeholderForArg( +// hereTile.maxLevelUpperBound().intKey, +// PgType.INT +// ) +// return "(${StandardMembers.HereTile} >= $lowerBoundPlaceholder AND ${StandardMembers.HereTile} <= $upperBoundPlaceholder)" +// } +// +// private fun whereMetadata() { +// val metaQuery = request.query.members +// if (metaQuery != null) { +// if (where.isNotEmpty()) { +// where.append(" AND (") +// } else { +// where.append(" (") +// } +// whereNestedMetadata(metaQuery) +// where.append(")") +// } +// } +// +// private fun whereNestedMetadata(metaQuery: IMemberQuery) { +// when (metaQuery) { +// is MemberNot -> not( +// subClause = metaQuery.query, +// subClauseResolver = this::whereNestedMetadata +// ) +// +// is MemberAnd -> and( +// subClauses = metaQuery.filterNotNull(), +// subClauseResolver = this::whereNestedMetadata +// ) +// +// is MemberOr -> or( +// subClauses = metaQuery.filterNotNull(), +// subClauseResolver = this::whereNestedMetadata +// ) +// +// is MemberQuery -> { +// val isActionQuery = metaQuery.member == MetaColumn.action() +// val pgColumn = +// if (isActionQuery) { +// StandardMembers.Version +// } else { +// PgColumn.ofRowColumn(metaQuery.member) ?: throw NakshaException( +// NakshaError.ILLEGAL_STATE, +// "Couldn't find PgColumn for TupleColumn: ${metaQuery.member.name}" +// ) +// } +// val leftOperand = if (isActionQuery) { +// "(${PgColumn.version.name} & 3)::int4" +// } else if (pgColumn == PgColumn.created_at || pgColumn == PgColumn.author_ts) { +// "COALESCE(${pgColumn.name}, ${PgColumn.updated_at.name})" +// } else { +// pgColumn.name +// } +// // Action lives in the lower 2 bits of `version`; the comparison value is a small int. +// val placeholderType = if (isActionQuery) PgType.INT else pgColumn.type +// val resolvedQuery = when (val op = metaQuery.op) { +// is StringOp -> { +// val placeholder = placeholderForArg(metaQuery.value, placeholderType) +// resolveStringOp(op, leftOperand, placeholder) +// } +// is DoubleOp -> { +// val placeholder = placeholderForArg(metaQuery.value, placeholderType) +// resolveDoubleOp(op, leftOperand, placeholder) +// } +// is AnyOp.IS_ANY_OF -> { +// val placeholder = placeholderForArg(metaQuery.value, arrayTypeFor(placeholderType)) +// "$leftOperand = ANY($placeholder)" +// } +// else -> throw illegalArg("Unknown op type: ${op::class.simpleName}") +// } +// where.append(resolvedQuery) +// } +// +// else -> throw NakshaException( +// NakshaError.ILLEGAL_ARGUMENT, +// "Unknown metadata query type: ${metaQuery::class.simpleName}" +// ) +// } +// } +// +// private fun arrayTypeFor(pgType: PgType): PgType { +// return when (pgType) { +// PgType.BOOLEAN -> PgType.BOOLEAN_ARRAY +// PgType.SHORT -> PgType.SHORT_ARRAY +// PgType.INT -> PgType.INT_ARRAY +// PgType.INT64 -> PgType.INT64_ARRAY +// PgType.FLOAT -> PgType.FLOAT_ARRAY +// PgType.DOUBLE -> PgType.DOUBLE_ARRAY +// PgType.STRING -> PgType.STRING_ARRAY +// PgType.BYTE_ARRAY -> PgType.BYTE_ARRAY_ARRAY +// else -> throw illegalArg("Unknown array type for PgType: ${pgType::class.simpleName}") +// } +// } +// +// private fun whereTags() { +// val tagQuery = request.query.tags +// if (tagQuery != null) { +// if (where.isNotEmpty()) { +// where.append(" AND (") +// } else { +// where.append(" (") +// } +// whereNestedTags(tagQuery) +// where.append(")") +// } +// } +// +// private fun whereNestedTags(tagQuery: ITagQuery) { +// when (tagQuery) { +// is TagSetContains -> resolveTagSetContains(tagQuery) +// is TagNot -> not(tagQuery.query, this::whereNestedTags) +// is TagOr -> { +// if(containsOnlyTagExists(tagQuery)){ +// // for tags without values we can utilize top-level-key based '?|' operand +// // https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-JSONB-OP-TABLE +// val tagNames = tagQuery.filterIsInstance().map { it.name } +// resolveTagNamesArrayOperation( +// jsonbOperator = "?|", // 'jsonb_exists_any' is equivalent but will not hit the GIN index +// tagNames = tagNames +// ) +// } else { +// or(tagQuery.filterNotNull(), this::whereNestedTags) +// } +// } +// is TagAnd -> { +// if(containsOnlyTagExists(tagQuery)){ +// // for tags without values we can utilize top-level-key based '?&' operand +// // https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-JSONB-OP-TABLE +// val tagNames = tagQuery.filterIsInstance().map { it.name } +// resolveTagNamesArrayOperation( +// jsonbOperator = "?&", // 'jsonb_exists_all' is equivalent but MIGHT not hit the GIN index +// tagNames = tagNames +// ) +// } else { +// and(tagQuery.filterNotNull(), this::whereNestedTags) +// } +// } +// is TagQuery -> resolveSingleTagQuery(tagQuery) +// } +// } +// +// private fun containsOnlyTagExists(container: ListProxy): Boolean = +// container.all { it == null || it is TagMapHasKey } +// +// /** +// * Element containment on a set-form tags column (jsonb array): `tags @> '[]'::jsonb`. +// * The `@>` operator matches the element in its type (string, boolean, number) and is supported +// * by the GIN index over the column. +// */ +// private fun resolveTagSetContains(tagQuery: TagSetContains) { +// val element = AnyList() +// element.add(tagQuery.element) +// val placeholder = placeholderForArg(toJSON(element), PgType.STRING) +// where.append("$tagsAsJsonb @> $placeholder::jsonb") +// } +// +// private fun resolveTagNamesArrayOperation(jsonbOperator: String, tagNames: List) { +// val tagKeysArray = tagNames.toTypedArray() +// val tagKeysPlaceholder = placeholderForArg(tagKeysArray, PgType.STRING_ARRAY) +// where.append("$tagsAsJsonb ?$jsonbOperator $tagKeysPlaceholder") +// } +// +// private fun resolveSingleTagQuery(tagQuery: TagQuery) { +// when (tagQuery) { +// is TagMapHasKey -> { +// val tagNamePlaceholder = placeholderForArg(tagQuery.name, PgType.STRING) +// where.append("$tagsAsJsonb ?? $tagNamePlaceholder") +// } +// +// is TagValueIsNull -> { +// val tagValuePlaceholder = placeholderForArg(selectTagValue(tagQuery), PgType.STRING) +// where.append("$tagValuePlaceholder IS NULL") +// } +// +// is TagValueIsBool -> { +// if (tagQuery.value) { +// where.append(selectTagValue(tagQuery, PgType.BOOLEAN)) +// } else { +// where.append("not(${selectTagValue(tagQuery, PgType.BOOLEAN)})") +// } +// } +// +// is TagValueIsDouble -> { +// val queryValuePlaceholder = placeholderForArg(tagQuery.value, PgType.DOUBLE) +// val doubleOp = resolveDoubleOp( +// tagQuery.op, +// selectTagValue(tagQuery, PgType.DOUBLE), +// queryValuePlaceholder +// ) +// where.append(doubleOp) +// } +// +// is TagValueIsString -> { +// val queryValuePlaceholder = placeholderForArg(tagQuery.value, PgType.STRING) +// val stringEquals = resolveStringOp( +// StringOp.EQUALS, +// selectTagValue(tagQuery, PgType.STRING), +// queryValuePlaceholder +// ) +// where.append(stringEquals) +// } +// +// is TagValueMatches -> { +// val jsonPathPlaceholder = placeholderForArg( +// "\$.${tagQuery.name} ? (@ like_regex \"${tagQuery.regex}\")", +// PgType.STRING +// ) +// where.append("$tagsAsJsonb @?? $jsonPathPlaceholder::jsonpath") +// } +// } +// } +// +// private fun selectTagValue(tagQuery: TagQuery, castTo: PgType? = null): String { +// val tagKeyPlaceholder = placeholderForArg(tagQuery.name, PgType.STRING) +// return when (castTo) { +// null -> "$tagsAsJsonb->$tagKeyPlaceholder" +// PgType.STRING -> "$tagsAsJsonb->>$tagKeyPlaceholder" +// else -> "($tagsAsJsonb->$tagKeyPlaceholder)::${castTo.value}" +// } +// } +// +// private fun not(subClause: T, subClauseResolver: (T) -> Unit) { +// where.append(" NOT (") +// subClauseResolver(subClause) +// where.append(") ") +// } +// +// private fun and(subClauses: List, subClauseResolver: (T) -> Unit) = +// multiClause("AND", subClauses, subClauseResolver) +// +// private fun or(subClauses: List, subClauseResolver: (T) -> Unit) = +// multiClause("OR", subClauses, subClauseResolver) +// +// private fun multiClause( +// operand: String, +// subClauses: List, +// subClauseResolver: (T) -> Unit +// ) { +// where.append(" (") +// subClauses.forEachIndexed { index, subClause -> +// if (index > 0) { +// where.append(" $operand ") +// } +// subClauseResolver(subClause) +// } +// where.append(") ") +// } +// +// +// private fun resolveStringOp( +// stringOp: StringOp, +// leftOperand: String, +// rightOperand: String +// ): String { +// return when (stringOp) { +// StringOp.EQUALS -> "$leftOperand = $rightOperand" +// StringOp.NOT_EQUALS -> "$leftOperand != $rightOperand" +// StringOp.STARTS_WITH -> "starts_with($leftOperand, $rightOperand)" +// else -> throw NakshaException( +// NakshaError.ILLEGAL_ARGUMENT, +// "Unknown StringOp: $stringOp" +// ) +// } +// } +// +// private fun resolveDoubleOp( +// doubleOp: DoubleOp, +// leftOperand: String, +// rightOperand: String +// ): String { +// return when (doubleOp) { +// DoubleOp.EQ -> "$leftOperand = $rightOperand" +// DoubleOp.NE -> "$leftOperand != $rightOperand" +// DoubleOp.GT -> "$leftOperand > $rightOperand" +// DoubleOp.GTE -> "$leftOperand >= $rightOperand" +// DoubleOp.LT -> "$leftOperand < $rightOperand" +// DoubleOp.LTE -> "$leftOperand <= $rightOperand" +// else -> throw NakshaException( +// NakshaError.ILLEGAL_ARGUMENT, +// "Unknown DoubleOp: $doubleOp" +// ) +// } +// } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereClause.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereClause.kt index 9e742f94f4..9d0293296d 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereClause.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgQueryWhereClause.kt @@ -1,27 +1,37 @@ package naksha.psql +import kotlin.jvm.JvmField + /** * A SQL `WHERE` query. + * @param collection the collection for which this `WHERE` query applies. + * @property collection the collection for which this `WHERE` query applies. * @since 3.0 */ internal data class PgQueryWhereClause( + @JvmField + val collection: PgCollection, + /** * The `WHERE` query, without the keyword `WHERE` or an empty string, if an empty query (query without conditions). * @since 3.0 */ + @JvmField val where: String, /** * The arguments to used with the WHERE in order. * @since 3.0 */ - val argValues: MutableList, + @JvmField + val argValues: List, /** * The types of the arguments. * @since 3.0 */ - val argTypes: MutableList, + @JvmField + val argTypes: List ) { /** * Returns the [argTypes] as typed-array _(`Array`)_. @@ -29,4 +39,6 @@ internal data class PgQueryWhereClause( */ val argTypeNames: Array get() = argTypes.map(PgType::toString).toTypedArray() + + override fun toString(): String = where } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRead.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRead.kt deleted file mode 100644 index bc30dfdd7c..0000000000 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRead.kt +++ /dev/null @@ -1,180 +0,0 @@ -package naksha.psql - -import naksha.base.Epoch -import naksha.model.* -import naksha.model.request.FeatureTuple - -/** - * Helper to simplify reading from partitioned tables. - * @since 3.0 - */ -internal data class PgRead( - /** - * The map to read from. - */ - val map: PgMap, - - /** - * The collection to read from. - */ - val collection: PgCollection, - - /** - * If a tuple by tuple-number should be read. - */ - val tupleNumber: TupleNumber?, - - /** - * If a tuple by id should be read. - */ - val featureId: String?, - - /** - * The amount of performance-partitions, `-1` if no performance partitions are there, otherwise a value between `2` and `1000`. - */ - val partitionCount: Int = if (collection.partitions > 1) collection.partitions else -1, - - /** - * If we need to read performance partitions. - */ - val readPartition: Boolean = partitionCount >= 0, - - /** - * If a tuple-number or id was given, the partition to read. Will be `-1` if either the collection does not have performance collections, or if we need to read all partitions. - */ - val partition: Int = if (!readPartition) -1 - else if (tupleNumber != null) tupleNumber.partitionNumber % collection.partitions - else if (featureId != null) Naksha.partitionNumber(featureId) % collection.partitions - else -1, - - /** - * A grouping identifier. - * - * Actually, allows grouping so that the same map/collection is merged, and that all reads that are from all partitions of a collection are grouped together, and all that read from the same partition. Therefore, eventually the first read from a group with the same group-id, will always read from the same map, collection, partition(s). - */ - val groupId: String = if (!readPartition) "${map.id}:${collection.id}" else "${map.id}:${collection.id}:${partition}" -) { - companion object PgRead_C { - private val UNDEFINED = ArrayList(0) - } - - /** - * Optional attachment that helps loading. - */ - var featureTuple: FeatureTuple? = null - - /** - * Read multiple features by arbitrary query from this collection. - * @param map the map to read. - * @param collection the collection to read. - */ - constructor(map: PgMap, collection: PgCollection): this(map, collection, null, null) - - /** - * Read a tuple by tuple-number. - * @param conn the database connection. - * @param adminMap the admin-map. - * @param tupleNumber the tuple-number of the tuple to read. - */ - constructor(conn: PgConnection, adminMap: PgAdminMap, tupleNumber: TupleNumber) : this( - adminMap.getPgMapByNumber(conn, tupleNumber.mapNumber) - ?: throw mapNotFound("The map for map-number ${tupleNumber.mapNumber} not found"), - adminMap.getPgMapByNumber(conn, tupleNumber.mapNumber)?.getPgCollectionByNumber(conn, tupleNumber.collectionNumber) - ?: throw collectionNotFound("The collection for collection-number ${tupleNumber.collectionNumber} not found"), - tupleNumber, - null - ) - - /** - * Read a tuple by feature-id. - * @param map the map to read. - * @param collection the collection to read. - * @param id the feature-id of the tuple to read. - */ - constructor(map: PgMap, collection: PgCollection, id: String) : this(map, collection, null, id) - - private fun initHeadTables(): List { - val headTable = collection.headTable - if (readPartition) { - val headPartitions = headTable.partitions - // If it is enough to read a single partition, because we know where the feature is - if (partition >= 0) return listOf(headPartitions[partition]) - // Otherwise read all partitions - val tables = ArrayList(headPartitions.size) - for (i in headPartitions.indices) { - tables.add(headPartitions[i]) - } - return tables - } - return listOf(headTable) - } - - private var _headTables: List? = UNDEFINED - /** - * All HEAD tables to read. - */ - val headTables: List - get() { - var tables = _headTables - if (tables !== UNDEFINED && tables != null) return tables - tables = initHeadTables() - _headTables = tables - return tables - } - - /** - * The meta-table to read. - */ - val metaTable: PgTable? = collection.metaTable - - fun initHistoryTables(): List? { - val history = collection.historyTable ?: return null - // TODO: hack to be be fixed as part of CASL-1095 - // it was observed that if collection is used for the first time (it is not cached) they `years` are empty - // even if the year partitions actually exist on DB side - // this results in returned history tables being empty (even though they can be there) - // this behavior was observedd during CASL-1057 development - if(history.years.isEmpty()){ - // see: PgMap.createPgCollection - val year = Epoch().year - history.addYear(year) - history.addYear(year + 1) - } - val tables = ArrayList(history.years.size) - for (entry in history.years) { - val year = entry.key - val historyTable = history.years[year] - ?: throw illegalState("Internal error, failed to add history year $year") - if (readPartition) { - val hstPartitions = historyTable.partitions - // If it is enough to read a single partition, because we know where the feature is - if (partition >= 0) { - tables.add(hstPartitions[partition]) - } else { - // Otherwise read all partitions - for (i in 0 ..< partitionCount) { - tables.add(hstPartitions[i]) - } - } - } else { - // When we do not performance-partition history, only table per year. - tables.add(historyTable) - } - } - return tables - } - - private var _historyTables: List? = UNDEFINED - - /** - * All history tables to read. - */ - val historyTables: List? - get() { - var tables = _historyTables - if (tables !== UNDEFINED) return tables - tables = initHistoryTables() - _historyTables = tables - return tables - } -} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgReader.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgReader.kt index 86173d5ad7..e28e185c0f 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgReader.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgReader.kt @@ -38,7 +38,7 @@ class PgReader( val session = this.session val query = PgQueryBuilder(session, request).build() val conn = session.useConnection() - session.storage.adminMap.setSearchPath(conn) + session.storage.adminCatalog.setSearchPath(conn) if (PlatformUtil.ENABLE_INFO) { if (session.logQueries) { session.logAtInfo(query.sql) @@ -63,7 +63,7 @@ class PgReader( val col_num: Int = collectionNumber ?: cursor["col_num"] val fn: naksha.base.Int64 = cursor["fn"] val version: naksha.base.Int64 = cursor["version"] - featureTuples.add(FeatureTuple(TupleNumber(storageNumber, mapNumber, col_num, fn, Version(version)))) + featureTuples.add(FeatureTuple(TupleNumber(storageNumber, mapNumber, col_num, fn, version))) } } return SuccessResponse().withFeatureTupleList(featureTuples) diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRelation.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRelation.kt index 476845beec..22b182d324 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRelation.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRelation.kt @@ -11,23 +11,23 @@ import kotlin.jvm.JvmStatic /** * Information extracted from the [pg_class](https://www.postgresql.org/docs/current/catalog-pg-class.html) table, about relations of a collection. - * @property oid the OID of the relation. - * @property name the name of the relation. - * @property schemaOid the OID of the schema in which the relation is located. - * @property schema_name the name of the schema in which the relation is located. - * @property kind the kind of relation. + * @property oid the OID of the relation (`oid`). + * @property rel_name the name of the relation (`relname`). + * @property schema_oid the OID of the schema in which the relation is located (`relnamespace`). + * @property schema_name the name of the schema in which the relation is located, subselected from [pg_namespace](https://www.postgresql.org/docs/current/catalog-pg-namespace.html) using [schema_oid]. + * @property kind the kind of relation (`relkind`). * @property storageClass the storage class of the relation. - * @property tablespaceOid the OID of the tablespace in which the relation is stored. + * @property tablespace_oid the OID of the tablespace in which the relation is stored (`reltablespace`). */ @JsExport data class PgRelation( @JvmField val oid: Int, - @JvmField val name: String, - @JvmField val schemaOid: Int, + @JvmField val rel_name: String, + @JvmField val schema_oid: Int, @JvmField val schema_name: String, @JvmField val kind: PgKind, @JvmField val storageClass: PgStorageClass, - @JvmField val tablespaceOid: Int + @JvmField val tablespace_oid: Int ) { /** * Create an information row from the given cursor, that need to be a cursor as returned by [select]. @@ -36,12 +36,12 @@ data class PgRelation( @JsName("fromCursor") constructor(cursor: PgCursor) : this( oid = cursor["oid"], - name = cursor["relname"], - schemaOid = cursor["schema_oid"], + rel_name = cursor["relname"], + schema_oid = cursor["schema_oid"], schema_name = cursor["schema_name"], kind = PgKind.of(cursor.column("kind") as String), storageClass = PgStorageClass.of(cursor["sc"]), - tablespaceOid = cursor["ts_oid"] + tablespace_oid = cursor["ts_oid"] ) /** @@ -62,114 +62,40 @@ data class PgRelation( */ fun isPartition() = kind === PgKind.PartitionedTable - private var _partNumber: Int? = null + /** The parts of the name. */ + private val parts = rel_name.split('$') /** - * Returns the partition number. - * @return the partition number or -1, when this is no partition. + * Returns the distribution partition of this relation. + * @return the distribution partition of this relation or -1, when this is not distribution partitioned. */ - fun partitionNumber(): Int { - var n = _partNumber - if (n != null) return n - n = -1 - var i = name.indexOf(PG_PART) - if (i > 0) { - i += PG_PART.length - if (i + 3 <= name.length) try { - n = name.substring(i, i + 3).toInt(10) - } catch (_: Exception) {} - } - this._partNumber = n - // TODO: KotlinCompilerBug - It should know that "n" is never null - // Either name.substring().toInt() returns or not, either way, n is a number! - return n!! + fun distributionPartition(): Int { + // 0 1 2 3 = 4 + // HISTORY: {name}$hst${shifted}${distribution} + if (parts.size == 4 && parts[1] == "hst") return parts[3].toInt() + + // 0 1 = 2 + // HEAD: {name}${distribution} + if (parts.size == 2 && parts[2] != "hst") return parts[1].toInt() + return -1 } - private var _year: Int? = null - - /** - * Returns the history partition key (`next_version >> shift`) when this is a history year-partition, - * or `-1` when this is not a year-partition. - * - * For the default [shift][naksha.model.objects.NakshaCollection.shift] of 41 this equals the calendar year. - * - * New naming: `{collection}$hst$` (e.g. `mycol$hst$2026`). - * Legacy naming (TRANSACTIONS table): `{table}$y` (e.g. `naksha~transactions$y2026`). - */ - fun year(): Int { - var n = _year - if (n != null) return n - n = -1 - // New history partition naming: $hst$ - val hstIdx = name.indexOf(PG_HST) - if (hstIdx > 0) { - val after = hstIdx + PG_HST.length // points at '$' before the key - if (after < name.length && name[after] == '$') { - val keyStart = after + 1 - val keyEnd = name.indexOf('$', keyStart).let { if (it < 0) name.length else it } - if (keyEnd > keyStart) try { - n = name.substring(keyStart, keyEnd).toInt(10) - } catch (_: Exception) {} - } - } - // Legacy TRANSACTIONS-table naming: $y<4-digit-year> - if (n < 0) { - var i = name.indexOf(PG_YEAR) - if (i > 0) { - i += PG_YEAR.length - if (i + 4 <= name.length) try { - n = name.substring(i, i + 4).toInt(10) - } catch (_: Exception) {} - } - } - this._year = n - return n!! - } - - fun isAnyHeadRelation() = name.indexOf(PG_DEL) < 0 && name.indexOf(PG_HST) < 0 && name.indexOf(PG_META) < 0 - fun isHeadRootRelation() = isAnyHeadRelation() && (isTable() || isPartition()) && name.indexOf(PG_PART) < 0 - fun isTxnYearRelation() = isAnyHeadRelation() && isTable() && name.indexOf(PG_YEAR) > 0 - - // --- - - fun isAnyDeleteRelation() = name.indexOf(PG_DEL) > 0 - fun isDeleteRootRelation() = isAnyDeleteRelation() && (isTable() || isPartition()) && name.indexOf(PG_PART) < 0 - - // --- + // 0 1 = 2 + // HEAD: {name}${distribution} + @JvmField + val isHeadTable = parts.size == 1 + @JvmField + val isHeadDistributionPartition = parts.size == 2 && parts[1] != "hst" + @JvmField + val isHead = isHeadTable || isHeadDistributionPartition - fun isAnyHistoryRelation() = name.indexOf(PG_HST) > 0 - /** History root: ends with `$hst` and has no further `$` segments after it. */ - fun isHistoryRootRelation(): Boolean { - if (!isAnyHistoryRelation() || (!isTable() && !isPartition())) return false - val hstIdx = name.indexOf(PG_HST) - return hstIdx + PG_HST.length == name.length // nothing after $hst - } - /** History year-partition: `$hst$` at end, no `$p` suffix. */ - fun isHistoryYearRelation(): Boolean { - if (!isAnyHistoryRelation() || (!isTable() && !isPartition())) return false - return year() > 0 && name.indexOf(PG_PART, name.indexOf(PG_HST)) < 0 - } - /** History perf-partition: `$hst$$p`. */ - fun isHistoryPartition(): Boolean { - if (!isAnyHistoryRelation() || !isTable()) return false - return year() > 0 && name.indexOf(PG_PART, name.indexOf(PG_HST)) >= 0 - } - - // --- - - /** - * Test if this is a HEAD relation (any table, partition or index that belongs to HEAD). - * @return _true_ if this is a HEAD relation. - */ - fun isAnyMetaRelation() = name.indexOf(PG_META) > 0 - - /** - * Tests if this the HEAD table, which means, that HEAD is not partitioned. - * @return _true_ if this is the HEAD table. - */ - fun isMetaRootRelation() = isAnyMetaRelation() && isTable() + // 0 1 2 3 = 4 + // HISTORY: {name}$hst${shifted}${distribution} - // --- + val isHistoryTable: Boolean = parts.size == 2 && parts[1] == "hst" + val isHistoryPartition: Boolean = parts.size == 3 && parts[1] == "hst" + val isHistoryDistributionPartition: Boolean = parts.size == 4 && parts[1] == "hst" + val isHistory = isHistoryTable || isHistoryPartition || isHistoryDistributionPartition companion object PgRelationCompanion { /** @@ -193,8 +119,7 @@ SELECT c.oid AS oid, c.reltablespace as ts_oid FROM pg_class c, i WHERE c.relnamespace = i.oid AND (c.relname=${quoteLiteral(collectionId)} OR c.relname LIKE ${quoteLiteral(collectionId, "${PG_S}%")}) -ORDER BY relname; -""" +ORDER BY relname;""" return conn.execute(SQL) } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRows.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRows.kt new file mode 100644 index 0000000000..55bacc9a4b --- /dev/null +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgRows.kt @@ -0,0 +1,474 @@ +package naksha.psql + +import naksha.base.Int64 +import naksha.geo.SpGeometry +import naksha.jbon.BookType +import naksha.jbon.HeapBook +import naksha.model.* +import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.objects.MemberType +import naksha.model.objects.StandardMembers.StandardMembers_C.Feature + +/** + * Helper class to convert rows into arrays of column-data and vice versa. The main purpose is to read and write full tuples, but it supports basically as well virtual columns. + * @since 3.0 + */ +internal class PgRows { + /** + * All columns being added already. + * @since 3.0 + */ + val columns = mutableListOf() + private var aliases: String? = null + private var namesAggregate: String? = null + private var placeholders: String? = null + private var arrayTypeNames: Array? = null + private fun clearCache(): PgRows { + aliases = null + namesAggregate = null + placeholders = null + arrayTypeNames = null + return this + } + + /** + * The amount of rows. + * @since 3.0 + */ + var size: Int = 0 + set(value) { + if (field != value) { + for (column in columns) { + column.values.size = value + } + field = value + } + } + + /** + * Ensures that all columns have at least this amount of values, if too short, adds `null` values until the minimal size is reached. + * @since 3.0 + */ + fun setMinRows(rowsCount: Int): PgRows { + if (this.size < rowsCount) { + this.size = rowsCount + for (column in columns) { + column.values.size = rowsCount + } + } + return this + } + + /** + * When set, clear the [columns], add all columns of the given [PgCollection], and set the [collectionNumber], [catalogNumber], and [databaseNumber]. Eventually this will read all columns, so a full member-book, from a specific database table, no matter if from _HISTORY_ or _HEAD_. + * @since 3.0 + */ + var collection: PgCollection? = null + set(collection) { + if (collection != null) { + clearCache() + columns.clear() + collectionNumber = collection.collectionNumber + catalogNumber = collection.catalog.catalogNumber + databaseNumber = collection.catalog.storage.number + for (pgColumn in collection.columns) { + columns.add(PgColumnWithValues(pgColumn)) + } + } + field = collection + } + + /** + * Clear the members, then add all members of the given [PgCollection] to the row-set. + * @param col the [PgCollection] of which to add the members. + * @return this + * @see [collection] + * @since 3.0 + */ + fun withCollection(col: PgCollection): PgRows { + this.collection = col + return this + } + + /** + * If all rows are coming from the same storage, the storage-number of it. + * @since 3.0 + */ + var databaseNumber: Int64? = null + set(value) { + collection = null + field = value + } + + /** + * @see [databaseNumber] + */ + fun withDatabaseNumber(value: Int64): PgRows { + databaseNumber = value + return this + } + + /** + * If all rows are coming from the same catalog, the catalog-number of it. + * @since 3.0 + */ + var catalogNumber: Int? = null + set(value) { + collection = null + field = value + } + + /** + * @see [catalogNumber] + */ + fun withCatalogNumber(value: Int): PgRows { + catalogNumber = value + return this + } + + /** + * If all rows are coming from the same collection, the collection-number of it. + * @since 3.0 + */ + var collectionNumber: Int? = null + set(value) { + collection = null + field = value + } + + /** + * @see [collectionNumber] + */ + fun withCollectionNumber(value: Int): PgRows { + collectionNumber = value + return this + } + + fun addColumns(columns: Array): PgRows { + for (column in columns) { + val alias = column.name + val existing = getColumn(alias) + if (existing != null) continue + this.columns.add(PgColumnWithValues(column, alias).withSize(size)) + } + return this + } + + fun addColumn(column: PgColumn, alias: String = column.name): PgRows { + clearCache() + val existing = getColumn(alias) + if (existing == null) { + val column = PgColumnWithValues(column, alias).withSize(size) + columns.add(column) + setMinRows(size) + } + return this + } + fun addColumn(alias: String, type: MemberType): PgRows { + clearCache() + val existing = getColumn(alias) + if (existing == null) { + val column = PgColumnWithValues(PgColumn(-1, alias, type)).withSize(size) + columns.add(column) + setMinRows(size) + } + return this + } + + fun getColumn(alias: String): PgColumnWithValues? { + for (column in columns) if (column.alias == alias) return column + return null + } + fun getColumn(index: Int): PgColumnWithValues? = if (index in 0 until columns.size) columns[index] else null + fun hasColumn(alias: String): Boolean = getColumn(alias) != null + fun hasColumn(index: Int): Boolean = getColumn(index) != null + + + fun getAny(row: Int, alias: String): Any? = getColumn(alias)?.values?.get(row) + fun getAny(row: Int, column: PgColumn): Any? = getAny(row, column.name) + fun getInt(row: Int, alias: String): Int? = getAny(row, alias) as? Int + fun getInt(row: Int, column: PgColumn): Int? = getInt(row, column.name) + fun getInt64(row: Int, alias: String): Int64? = getAny(row, alias) as Int64? + fun getInt64(row: Int, column: PgColumn): Int64? = getInt64(row, column.name) + fun getDouble(row: Int, alias: String): Double? = getAny(row, alias) as Double? + fun getDouble(row: Int, column: PgColumn): Double? = getDouble(row, column.name) + fun getString(row: Int, alias: String): String? = getAny(row, alias) as String? + fun getString(row: Int, column: PgColumn): String? = getString(row, column.name) + fun getByteArray(row: Int, alias: String): ByteArray? = getAny(row, alias) as ByteArray? + fun getByteArray(row: Int, column: PgColumn): ByteArray? = getByteArray(row, column.name) + fun getSpatial(row: Int, alias: String): SpGeometry? { + val raw = getByteArray(row, alias) ?: return null + return try { + Naksha.decodeGeometry(raw) + } catch (_: Exception) { + null + } + } + fun getTags(row: Int, alias: String): TagMap? { + val raw = getString(row, alias) ?: return null + return try { + Naksha.decodeTags(raw) + } catch (_: Exception) { + null + } + } + fun getTagList(row: Int, alias: String): TagList? { + val raw = getString(row, alias) ?: return null + return try { + Naksha.decodeTagList(raw) + } catch (_: Exception) { + null + } + } + fun getB64(row: Int, columnName: String, featureNumber: Int64): TupleNumber? { + val raw = getByteArray(row, columnName) ?: return null + val storageNumber = this.databaseNumber ?: return null + val mapNumber = this.catalogNumber ?: return null + val collectionNumber = this.collectionNumber ?: return null + return try { + TupleNumber.fromB64(raw, storageNumber, mapNumber, collectionNumber, featureNumber) + } catch (_: Exception) { + null + } + } + fun getB128(row: Int, columnName: String): TupleNumber? { + val raw = getByteArray(row, columnName) ?: return null + val storageNumber = this.databaseNumber ?: return null + val mapNumber = this.catalogNumber ?: return null + val collectionNumber = this.collectionNumber ?: return null + return try { + TupleNumber.fromB128(raw, storageNumber, mapNumber, collectionNumber) + } catch (_: Exception) { + null + } + } + + /** + * Read the given row into a tuple, requires that a [collection] is assigned. + * @param row the row number. + * @return the [Tuple] extracted from the row or `null`, if either no [collection] set, the row number is outside the result-set, or something else failed. + * @since 3.0 + */ + fun getTuple(row: Int): Tuple? { + if (row !in 0..< size) return null + val collection = this.collection ?: return null + val membersBook = HeapBook(BookType.MEMBER_BOOK) + var featureBytes: ByteArray? = null + for (column in columns) { + val value = column.values[row] + if (Feature.name == column.pgColumn.name) { + // Special case, root feature. + if (value !is ByteArray) throw NakshaException(ILLEGAL_STATE, "The feature root is no byte-array") + featureBytes = value + } else { + val pgColumn = collection.column(column.pgColumn.name) + if (pgColumn != null) membersBook.put(pgColumn.name, value) + // else: We have and additional row in the result set that we can ignore for tuple fetching. + } + } + if (featureBytes == null) throw NakshaException(ILLEGAL_STATE, "Missing mandatory member '${Feature.name}'!") + return Tuple(featureBytes = featureBytes, membersBook = membersBook) + } + + operator fun get(row: Int): Tuple? = getTuple(row) + + fun set(row: Int, columnName: String, value: Any?): Boolean { + val column = getColumn(columnName) + if (column != null) { + setMinRows(row) + column.values[row] = value + return true + } + return false + } + + operator fun set(row: Int, tuple: Tuple) { + setMinRows(row) + val membersBook = tuple.membersBook + val END = membersBook.namesLength() + for (i in 0 until END) { + val memberName = membersBook.getNameAt(i) ?: continue + val column = getColumn(memberName) ?: continue + val value = membersBook[memberName] + column.values[row] = value + } + val featureColumn = getColumn(Feature.name) ?: return + featureColumn.values[row] = tuple.featureBytes + } + + /** + * Copies the columns from the cursor to the given position. The method does nothing, if the given cursor is not positioned at a row ([PgCursor.isRow] is _false_). + * @param row the row-number to set, if the given cursor is at a valid row. + * @param cursor the cursor to read from. + * @since 3.0 + */ + operator fun set(row: Int, cursor: PgCursor) { + if (!cursor.isRow()) return + setMinRows(row) + val columnNames = cursor.columnNames() + for (columnName in columnNames) { + val column = getColumn(columnName) ?: continue + val value = cursor.column(columnName) + column.values[row] = value + } + } + + /** + * Read from the given cursor and add a row to the end of the row-set. Requires that the cursor is positioned on a row _([PgCursor.isRow])_. Does not move the cursor forward. + * @param cursor the cursor from which to read. + * @return `true` if a row was read; `false` if the cursor is not at a valid row _([PgCursor.isRow] is _false_). + * @since 3.0 + */ + fun read(cursor: PgCursor): Boolean { + if (cursor.isRow()) { + set(size ,cursor) + return true + } + return false + } + + /** + * Read all rows from cursor to the end of the rows, expects the cursor to be positioned at the first row that should be read, usage: + * ```kotlin + * val rows = PgRows().withCollection(pgCollection) + * plan.execute(query).fetch().use { rows.readAll(it) } + * // Process the rows + * ``` + * @since 3.0 + */ + fun readAll(cursor: PgCursor): PgRows { + while (read(cursor)) cursor.next() + return this + } + +// /** +// * Read all rows from cursor, expects the cursor to be at first result and that for each column, there is an array of values, so an aggregate generated via `ARRAY_AGG`. +// * @since 3.0 +// */ +// fun addAggregated(cursor: PgCursor): PgRows { +// if (cursor.isRow()) { +// for (column in columns) { +// if (cursor.contains(column.name)) { +// val values = cursor.column(column.name) +// if (values is Array<*>) { +// withMinRows(values.size) +// for (i in 0 until values.size) { +// set(i, column.name, values[i]) +// } +// } +// } +// } +// } +// return this +// } + +// /** +// * Returns the names of all columns as comma separated string, surrounded with aggregation instruction, _(like `ARRAY_AGG(id)`)_, usage: +// * +// * ```kotlin +// * val rows = PgColumnRows().addColumns(allColumns) +// * val SQL = """SELECT ${rows.aliasesAggregate()} +// * FROM "naksha~admin".${collections.head.quotedName} +// * WHERE id = ANY($1)""" +// * val plan = conn.prepare(SQL, rows.typeNames()) +// * val cursor = plan.execute(rows.valuesExecutable()) +// * ``` +// * +// * @return the aliases of all columns as comma separated string, surrounded with aggregation instruction, example: +// * ```sql +// * ARRAY_AGG("foo") AS "foo", ARRAY_AGG("bar") AS "bar", ... +// * ``` +// * @since 3.0 +// */ +// fun aliasesAggregate(): String { +// val cached = this.namesAggregate +// if (cached != null) return cached +// val names = columns.joinToString(", ") { +// val q = PgUtil.quoteIdent(it.alias) +// "ARRAY_AGG($q) AS $q" +// } +// this.namesAggregate = names +// return names +// } + + /** + * Returns the aliases of all columns as comma separated string, optionally quoted. + * @return the aliases of all columns as comma separated string. + * @since 3.0 + */ + fun aliases(): String { + var aliases = this.aliases + if (aliases != null) return aliases + aliases = columns.joinToString(", ") { PgUtil.quoteIdent(it.alias) } + this.aliases = aliases + return aliases + } + + /** + * Returns the placeholders of all columns as comma separated string _($1, $2, ...)_, usage: + * + * ```kotlin + * val sql = """WITH new_row AS ( + * SELECT * FROM UNNEST(${rows.placeholders()}) + * AS t(${rows.names()}) + * ) + * INSERT INTO ${collection.head.quotedName} (${rows.aliases()}) + * SELECT * FROM new_row""" + * val plan = conn.prepare(sql, rows.typeNames()) + * val cursor = plan.execute(rows.values()) + * ``` + * + * @return the placeholders of all columns as comma separated string. + * @since 3.0 + */ + fun placeholders(): String { + var placeholders = this.placeholders + if (placeholders != null) return placeholders + val sb = StringBuilder() + for (i in 0 ..< columns.size) { + if (!sb.isEmpty()) sb.append(',') + sb.append('$').append(i) + } + placeholders = sb.toString() + this.placeholders = placeholders + return placeholders + } + + /** + * Returns the array type-names of all columns, for example, when the type is [PgType.INT64], it will return `int8[]`, usage: + * + * ```kotlin + * val sql = """WITH new_row AS ( + * SELECT * FROM UNNEST(${rows.placeholders()}) + * AS t(${rows.names()}) + * ) + * INSERT INTO ${collection.head.quotedName} (${rows.aliases()}) + * SELECT * FROM new_row""" + * val plan = conn.prepare(sql, rows.typeNames()) + * val cursor = plan.execute(rows.values()) + * ``` + * @return the array type-names of all columns. + * @since 3.0 + */ + fun typeNames(): Array = Array(columns.size) { columns[it].pgColumn.pgType.text + "[]" } + + /** + * Returns the values of all columns cast to a type that is supported by [PgPlan.execute], usage: + * + * ```kotlin + * val sql = """WITH new_row AS ( + * SELECT * FROM UNNEST(${rows.placeholders()}) + * AS t(${rows.names()}) + * ) + * INSERT INTO ${collection.head.quotedName} (${rows.aliases()}) + * SELECT * FROM new_row""" + * val plan = conn.prepare(sql, rows.typeNames()) + * val cursor = plan.execute(rows.values()) + * ``` + * Beware that the array really is two-dimensional: `Array>`. + * @return the values of all columns as `Array`. + * @since 3.0 + */ + @Suppress("UNCHECKED_CAST") + fun values(): Array = Array(columns.size) { columns[it].anyArray() } as Array +} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgSession.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgSession.kt index f4888dd4b9..40c61db46e 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgSession.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgSession.kt @@ -6,16 +6,18 @@ import naksha.base.* import naksha.base.Platform.PlatformCompanion.logger import naksha.base.Platform.PlatformCompanion.longToInt64 import naksha.base.Platform.PlatformCompanion.newAtomicInt64 +import naksha.base.PlatformDataViewApi.PlatformDataViewApiCompanion.dataview_set_int64 import naksha.model.* import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_STATE +import naksha.model.NakshaError.NakshaErrorCompanion.INTERNAL_ERROR import naksha.model.objects.NakshaCollection -import naksha.model.objects.NakshaMap +import naksha.model.objects.NakshaCatalog +import naksha.model.objects.NakshaFeature import naksha.model.request.* import naksha.model.request.WriteRequest import naksha.model.objects.NakshaTx import kotlin.js.JsExport import kotlin.jvm.JvmField -import kotlin.math.min /** * A session linked to a PostgresQL database. @@ -93,9 +95,9 @@ open class PgSession( } /** - * Assert that this session mutable. - * - Throws [NakshaError.ILLEGAL_STATE] if this session is [closed][isClosed]. + * Assert that this session is closed. * @since 3.0 + * @throws NakshaException with [ILLEGAL_STATE] if this session is [closed][isClosed]. */ fun assertOpen() { if (_closed) throw NakshaException(ILLEGAL_STATE, "Connection closed") @@ -220,7 +222,7 @@ open class PgSession( * The current transaction wrapper; if any. * @since 3.0 */ - internal var tx: StorageTx? = null + internal var tx: PgTx? = null private set /** @@ -228,19 +230,19 @@ open class PgSession( * @return the current transaction. * @since 3.0 */ - internal fun useTx(): StorageTx { + internal fun useTx(): PgTx { assertMutable() assertOpen() - var tx: StorageTx? = this.tx + var tx: PgTx? = this.tx if (tx == null) { - val txn = storage.newConnection(options, false, null).use { conn -> storage.adminMap.newTxn(conn) } - tx = StorageTx(storage, txn.version, options.appId, options.author, storage.adminMap) + val txn = storage.newConnection(options, false, null).use { conn -> storage.adminCatalog.newTxn(conn) } + tx = PgTx(storage, txn.version, options.appId, options.author, storage.adminCatalog, this) this.tx = tx } return tx } - override fun getTransaction(): NakshaTx? = tx?.transaction + override fun getTransaction(): NakshaTx? = tx?.nakshaTx /** * Return the current transaction, if no transaction started yet, starts a new one. @@ -248,7 +250,7 @@ open class PgSession( * - Throws [NakshaError.ILLEGAL_STATE] if this is session is [readOnly] or [closed][isClosed]. * @return the current transaction. */ - override fun useTransaction(): NakshaTx = useTx().transaction + override fun useTransaction(): NakshaTx = useTx().nakshaTx private var executionCount: Int = 0 @@ -306,8 +308,8 @@ open class PgSession( val tx = tx if (tx != null) { try { - val transaction = tx.transaction - val writeTx = Write().createFeature(Naksha.ADMIN_MAP, TRANSACTIONS_COL, transaction) + val transaction = tx.nakshaTx + val writeTx = Write().createFeature(Naksha.ADMIN_CATALOG_ID, TRANSACTIONS_COL, transaction) val writeRequest = WriteRequest().add(writeTx) // TODO: Should we use a savepoint here? val writer = PgWriter(this, false) @@ -341,6 +343,13 @@ open class PgSession( private var _closed = false + /** + * Registered member processors, keyed by member name. + * Processors are invoked in the order in which they were added. + * @since 3.0 + */ + override val processors = MemberProcessorMap() + override fun isClosed(): Boolean = _closed override fun close() { @@ -363,200 +372,192 @@ open class PgSession( return PgLock(this, useConnection(), lockId, false) } - override fun loadTuples(featureTuples: List) = loadTuples(featureTuples, 0, featureTuples.size, FETCH_ALL) - - override fun loadTuples(featureTuples: List, from: Int, to: Int, mode: FetchMode) { + override fun loadTuples(featureTuples: List, from: Int, to: Int) { val missing = featureTuples.subList(from, to).mapNotNull { if (it != null && it.tuple == null) it else null } if (missing.isNotEmpty()) { (if (mayReadParallel) newReadConnection() else readConnection()).use { readConn -> val conn = readConn.conn - val byCollection = mutableMapOf>() - val adminMap = storage.adminMap + val adminCatalog = storage.adminCatalog + val byCollection: MutableMap = mutableMapOf() for (featureTuple in missing) { - val read = PgRead(conn, adminMap, featureTuple.tupleNumber) - read.featureTuple = featureTuple - var reads = byCollection[read.groupId] - if (reads == null) { - reads = ArrayList(min(1000, missing.size)) - byCollection[read.groupId] = reads + val tn = featureTuple.tupleNumber + val collection: PgCollection = adminCatalog.getPgCollectionByNumber(conn, tn.collectionNumber) ?: continue + var list = byCollection[collection] + if (list == null) { + list = FeatureTupleList() + byCollection[collection] = list } - reads.add(read) + list.add(featureTuple) } - for (entry in byCollection) { - loadTuplesFromCollection(conn, entry.value, mode) + for ((collection, featureTuples) in byCollection) { + loadTuplesFromCollection(conn, collection, featureTuples) } } } } - /** - * Returns the effective HEAD-table column list for the given collection. - * - * For backward-compatible collections ([NakshaCollection.members] is `null`) this is the full - * Delegates to [PgCollection.effectiveHeadColumns]. - */ - private fun effectiveHeadColumns(collection: PgCollection): List = - collection.effectiveHeadColumns - /** * Load [Tuple] from a specific collection, can be executed in parallel, when multiple collections are needed. We should make parallel reading optional, we experienced that when used for example in EMR, too many connections can harm. However, the cache could keep objects in Redis or alike, and then read perfectly fine in parallel! * * @param conn the connection to use for this read. - * @param reads the reads to perform. - * @param mode the load-mode + * @param collection the collection to read. + * @param featureTuples the tuple to load. */ - private fun loadTuplesFromCollection(conn: PgConnection, reads: List, mode: FetchMode) { - // TODO: We can improve this to load the results as GZIP compressed binary! - // Read BINARY.md for more information. - // For the sake of delivery, we take the shortcut, and only us ARRAY_AGG - // Maybe this is already fast enough? - if (reads.isEmpty()) throw illegalState("Reads must not be empty") - val first = reads.first() - val map = first.map - val collection = first.collection - val historyTables = first.historyTables - // When history tables are included in the read, we need `next_version` in the result set - // (for the UNION ALL to have matching columns and so history tuples carry their next_version). - // For HEAD-only reads it is absent from the physical table, so we skip it and getTuple - // reads it as null (correct for live HEAD rows). - val effectiveCols = if (historyTables != null) - collection.effectiveHistoryColumns - else - collection.effectiveHeadColumns - val rows = PgColumnRows() - .withStorageNumber(map.storage.number) - .withMapNumber(map.number) - .withCollectionNumber(collection.number) - .withDefaultDataEncoding(collection.head.dataEncoding ?: Naksha.DEFAULT_DATA_ENCODING) - .addColumns(effectiveCols) - map.setSearchPath(conn) - val headTables = first.headTables - val sql = StringBuilder() - // Prefix selected columns with `t.` so they don't collide with `fn` / `version` from the lookup CTE. - val prefixedRowNames = rows.columns.joinToString(", ") { "t.${it.name}" } - // HEAD has no `next_version` column; substitute NULL for live rows, but for tombstones - // (version & 3 == 2) set next_version = version so the tombstone is self-referential (terminal). - val prefixedRowNamesForHead = rows.columns.joinToString(", ") { - if (it.name == PgColumn.next_version.name) - "CASE WHEN (t.version & 3) >= 2 THEN t.version ELSE NULL::int8 END AS ${it.name}" - else "t.${it.name}" + private fun loadTuplesFromCollection(conn: PgConnection, collection: PgCollection, featureTuples: FeatureTupleList): Int { + if (featureTuples.isEmpty()) return 0 + val HEAD_TABLE = collection.headTable.quotedName + val HISTORY_TABLE = collection.historyTable.quotedName + // Generate input array + val fn_version_bytes = ByteArray(featureTuples.size * 16) + val view = Platform.newDataView(fn_version_bytes) + for (i in 0..< featureTuples.size) { + val tn = featureTuples[i]?.tupleNumber ?: throw NakshaException(INTERNAL_ERROR, "featureTuples[$i] is null") + // Note: We do the math by intention. The CPU is very good at math, it's basically free. + // However, memory access is really slow, by doing it this way, the CPU can reorder + // the memory access, and the JIT can unroll the loop. + dataview_set_int64(view, i*16, tn.featureNumber) + dataview_set_int64(view, i*16 + 8, tn.version) } - sql.append("WITH ").append(TUPLE_LOOKUP_CTE).append(",\nresult AS(\n") - var unionAll = false - for (headTable in headTables) { - if (unionAll) sql.append("\tUNION ALL\n") else unionAll = true - sql.append("SELECT ").append(prefixedRowNamesForHead) - .append(" FROM ").append(headTable.quotedName) - .append(" t JOIN lookup l ON (t.fn, t.version) = (l.fn, l.version)\n") - } - if (historyTables != null) { - for (hstTable in historyTables) { - if (unionAll) sql.append("\tUNION ALL\n") else unionAll = true - sql.append("SELECT ").append(prefixedRowNames) - .append(" FROM ").append(hstTable.quotedName) - .append(" t JOIN lookup l ON (t.fn, t.version) = (l.fn, l.version)\n") - } - } else { - // No history tables: HEAD contains tombstones for deleted rows; no separate shadow table. - } - sql.append(")\nSELECT ").append(rows.namesAggregate()).append(" FROM result") - val SQL = sql.toString() - val tupleNumbers: Array = reads.map { it.tupleNumber!!.toB128() }.toTypedArray() + val SQL = """ +WITH lookup AS ( + SELECT + ((get_byte(b, 0) & 255)::bigint << 56) | + ((get_byte(b, 1) & 255)::bigint << 48) | + ((get_byte(b, 2) & 255)::bigint << 40) | + ((get_byte(b, 3) & 255)::bigint << 32) | + ((get_byte(b, 4) & 255)::bigint << 24) | + ((get_byte(b, 5) & 255)::bigint << 16) | + ((get_byte(b, 6) & 255)::bigint << 8) | + (get_byte(b, 7) & 255)::bigint AS fn, + + ((get_byte(b, 8) & 255)::bigint << 56) | + ((get_byte(b, 9) & 255)::bigint << 48) | + ((get_byte(b, 10) & 255)::bigint << 40)| + ((get_byte(b, 11) & 255)::bigint << 32)| + ((get_byte(b, 12) & 255)::bigint << 24)| + ((get_byte(b, 13) & 255)::bigint << 16)| + ((get_byte(b, 14) & 255)::bigint << 8) | + (get_byte(b, 15) & 255)::bigint AS version + FROM unnest($1::bytea[]) AS t(b) +), from_head AS ( + SELECT head.* FROM $HEAD_TABLE head + JOIN lookup l ON (head.fn, head.version) = (l.fn, l.version) +), from_hst AS ( + SELECT hst.* FROM $HISTORY_TABLE hst + JOIN lookup l ON (hst.fn, hst.version) = (l.fn, l.version) +) +SELECT * FROM from_head +UNION ALL +SELECT * FROM from_hst""" + collection.catalog.setSearchPath(conn) + val rows = PgRows().withCollection(collection) conn.prepare(SQL, arrayOf(PgType.BYTE_ARRAY_ARRAY.text)).use { plan -> - plan.execute(arrayOf(tupleNumbers)).fetch().use { cursor -> - rows.addAggregated(cursor) + plan.execute(arrayOf(fn_version_bytes)).fetch().use { cursor -> + rows.readAll(cursor) } } + // Copy tuples into cache, which allows us in the next loop to read the tuple back from the cache. for (i in 0 until rows.size) { val tuple = rows[i] ?: continue Naksha.cache.store(tuple) - val tupleNumber = tuple.tupleNumber - for (read in reads) { - if (read.tupleNumber == tupleNumber) { - read.featureTuple?.tuple = tuple - break + } + var found = 0 + for (i in 0..< featureTuples.size) { + val featureTuple = featureTuples[i] ?: throw NakshaException(INTERNAL_ERROR, "featureTuples[$i] is null") + val tn = featureTuple.tupleNumber + val tuple = Naksha.cache[tn] + if (tuple != null) { + // TODO: We need a cache for global books! + // We can simply say that the global books need to be committed, before they can be used. + // Additionally, they are immutable, onces stored, they can not be deleted nor mutated. + // Therefore, if a + val feature: NakshaFeature = tuple.decodeFeature(null) + featureTuple.feature = feature + if (featureTuple.id != feature.id) { + // This must not happen! + val tn = featureTuple.tupleNumber + throw NakshaException( + INTERNAL_ERROR, + "The `id` of feature-tuple '${feature.id}' does not match that of the loaded tuple: ${feature.id}, feature-number: '${tn.featureNumber}', version: ${tn.version}, collection: ${collection.id}" + ) } + found++ } } + return found } - override fun getMapById(mapId: String): NakshaMap? { - assertOpen() - return (if (mayReadParallel) newReadConnection() else readConnection()).use { - storage.adminMap.getPgMapById(it.conn, mapId)?.head - } - } + override fun getCatalogById(catalogId: String): NakshaCatalog? = getPgCatalogById(catalogId)?.head /** - * Returns the [PgMap] for the given id. - * @param mapId the map-id. - * @return the [PgMap]; _null_ if the map does not yet exist. + * Returns the [PgCatalog] for the given id. + * @param catalogId the catalog-id. + * @return the [PgCatalog]; _null_ if the map does not yet exist. */ - fun getPgMapById(mapId: String): PgMap? { + fun getPgCatalogById(catalogId: String): PgCatalog? { + val adminCatalog = storage.adminCatalog + val cachedCatalog = adminCatalog.getPgCatalogById(null, catalogId) + if (cachedCatalog != null) return cachedCatalog assertOpen() return (if (mayReadParallel) newReadConnection() else readConnection()).use { - storage.adminMap.getPgMapById(it.conn, mapId) + adminCatalog.getPgCatalogById(it.conn, catalogId) } } - override fun getMapByNumber(mapNumber: Int): NakshaMap? { - assertOpen() - return (if (mayReadParallel) newReadConnection() else readConnection()).use { - storage.adminMap.getPgMapByNumber(it.conn, mapNumber)?.head - } - } + override fun getCatalogByNumber(catalogNumber: Int): NakshaCatalog? = getPgCatalogByNumber(catalogNumber)?.head /** - * Returns the [PgMap] for the given number. - * @param mapNumber the map-number. - * @return the [PgMap]; _null_ if the map does not yet exist. + * Returns the [PgCatalog] for the given number. + * @param catalogNumber the catalog-number. + * @return the [PgCatalog]; _null_ if the map does not yet exist. */ - fun getPgMapByNumber(mapNumber: Int): PgMap? { + fun getPgCatalogByNumber(catalogNumber: Int): PgCatalog? { + val adminCatalog = storage.adminCatalog + val cachedCatalog = adminCatalog.getPgCatalogByNumber(null, catalogNumber) + if (cachedCatalog != null) return cachedCatalog assertOpen() return (if (mayReadParallel) newReadConnection() else readConnection()).use { - storage.adminMap.getPgMapByNumber(it.conn, mapNumber) + adminCatalog.getPgCatalogByNumber(it.conn, catalogNumber) } } - override fun getCollectionById(map: NakshaMap, collectionId: String): NakshaCollection? { - assertOpen() - return (if (mayReadParallel) newReadConnection() else readConnection()).use { - val pgMap = storage.adminMap.getPgMapById(it.conn, map.id) ?: return null - pgMap.getPgCollectionById(it.conn, collectionId)?.head - } + override fun getCollectionById(catalog: NakshaCatalog, collectionId: String): NakshaCollection? { + val pgCatalog = getPgCatalogById(catalog.id) ?: return null + return getPgCollectionById(pgCatalog, collectionId)?.head } /** * Returns the [PgCollection] for the given id. - * @param pgMap the [PgMap] in which to search for the collection. + * @param pgCatalog the [PgCatalog] in which to search for the collection. * @param collectionId the collection-id. * @return the [PgCollection]; _null_ if the collection does not yet exist. */ - fun getPgCollectionById(pgMap: PgMap, collectionId: String): PgCollection? { + fun getPgCollectionById(pgCatalog: PgCatalog, collectionId: String): PgCollection? { + val cachedCollection = pgCatalog.getPgCollectionById(null, collectionId) + if (cachedCollection != null) return cachedCollection assertOpen() return (if (mayReadParallel) newReadConnection() else readConnection()).use { - pgMap.getPgCollectionById(it.conn, collectionId) + pgCatalog.getPgCollectionById(it.conn, collectionId) } } - override fun getCollectionByNumber(map: NakshaMap, collectionNumber: Int): NakshaCollection? { - assertOpen() - return (if (mayReadParallel) newReadConnection() else readConnection()).use { - val pgMap = storage.adminMap.getPgMapById(it.conn, map.id) ?: return null - pgMap.getPgCollectionByNumber(it.conn, collectionNumber)?.head - } + override fun getCollectionByNumber(catalog: NakshaCatalog, collectionNumber: Int): NakshaCollection? { + val pgCatalog = getPgCatalogById(catalog.id) ?: return null + return getPgCollectionByNumber(pgCatalog, collectionNumber)?.head } /** * Returns the [PgCollection] for the given number. - * @param pgMap the [PgMap] in which to search for the collection. + * @param pgCatalog the [PgCatalog] in which to search for the collection. * @param collectionNumber the collection-number. * @return the [PgCollection]; _null_ if the collection does not yet exist. */ - fun getPgCollectionByNumber(pgMap: PgMap, collectionNumber: Int): PgCollection? { + fun getPgCollectionByNumber(pgCatalog: PgCatalog, collectionNumber: Int): PgCollection? { + val cachedCollection = pgCatalog.getPgCollectionByNumber(null, collectionNumber) + if (cachedCollection != null) return cachedCollection assertOpen() return (if (mayReadParallel) newReadConnection() else readConnection()).use { - pgMap.getPgCollectionByNumber(it.conn, collectionNumber) + pgCatalog.getPgCollectionByNumber(it.conn, collectionNumber) } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgStorage.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgStorage.kt index 34814796d6..f3dd07767c 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgStorage.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgStorage.kt @@ -75,20 +75,20 @@ import kotlin.js.JsExport @JsExport abstract class PgStorage protected constructor() : AbstractStorage() { - private var _adminMap: PgAdminMap? = null + private var _adminMap: PgAdminCatalog? = null /** * The admin-map, set by [initStorage]. * @since 3.0 */ - open val adminMap: PgAdminMap + open val adminCatalog: PgAdminCatalog get() = _adminMap ?: throw NakshaException(UNINITIALIZED, "Storage uninitialized") /** * Private setter for the admin map, to be used in the deriving storage class. * @since 3.0 */ - protected fun setAdminMap(adminMap: PgAdminMap) { + protected fun setAdminMap(adminMap: PgAdminCatalog) { _adminMap = adminMap } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgTable.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgTable.kt index 1109e9c996..799f686716 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgTable.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgTable.kt @@ -2,11 +2,6 @@ package naksha.psql -import naksha.model.illegalState -import naksha.model.objects.Index -import naksha.model.objects.IndexType -import naksha.model.objects.MemberList -import naksha.model.objects.MemberType import naksha.psql.PgUtil.PgUtilCompanion.quoteIdent import kotlin.js.JsExport import kotlin.js.JsStatic @@ -15,122 +10,69 @@ import kotlin.jvm.JvmStatic /** * Information about a single database table. - * @see [PgHead] - * @see [PgTransactions] - * @see [PgDeleted] - * @see [PgHistory] - * @see [PgMeta] + * @see [PgHeadTable] + * @see [PgHistoryTable] + * @see [PgMetaTable] */ @JsExport -open class PgTable( +abstract class PgTable( /** * The collection to which the table belongs. * @since 3.0 */ @JvmField val collection: PgCollection, + /** * The table-name. * @since 3.0 */ @JvmField val name: String, - /** - * The storage-class where the table is located. - * @since 3.0 - */ - @JvmField val storageClass: PgStorageClass, - /** - * If the table is volatile (is updated often); `false` only for history tables. - * @since 3.0 - */ - @JvmField val isVolatile: Boolean, + /** * The parent table, if this is a partition of it. * @since 3.0 */ - @JvmField val partitionOfTable: PgTable? = null, - /** - * If this is a partition of a [history table][PgHistory], the year; otherwise, if this is a performance partition, the index in the partitions array, so a value between 0 and n, with n being `partitionOf.partitionCount - 1`. - * @since 3.0 - */ - @JvmField val partitionOfValue: Int = -1, - /** - * The column by which to partition, if this is a partitioned table; must be either [PgColumn.fn] or [PgColumn.next_version]. - * @since 3.0 - */ - @JvmField val partitionByColumn: PgColumn? = null, - /** - * If this table is performance-partitioned, the amount of partitions, therefore - * - `0` when [partitionByColumn] is `null` - * - `0` when [partitionByColumn] is [PgColumn.next_version], so the partitions are years. - * - `2 .. 1000` when [partitionByColumn] is [PgColumn.fn], so this is a performance-partitioned table. - * @since 3.0 - */ - @JvmField val partitionCount: Int = 0 + @JvmField val parent: PgTable? = null, ) { companion object PgTableCompanion { /** - * Tests if this is any HEAD table _(either root or a performance-partition)_. + * Tests if this is any HEAD table, either root or a distribution-partition. * @param name the table name. * @return _true_ if this is any HEAD table. */ @JvmStatic @JsStatic - fun isAnyHead(name: String): Boolean = isHead(name) || isHeadPartition(name) + fun isAnyHead(name: String): Boolean = isHead(name) || isHeadDistributionPartition(name) /** - * Tests if this is the root HEAD table. + * Tests if this is the root HEAD table, i.e. `foo`. * @param name the relation name. * @return _true_ if this is the root HEAD table. */ @JvmStatic @JsStatic - fun isHead(name: String): Boolean = name.indexOf(PG_S) < 0 // does not contain a separator + fun isHead(name: String): Boolean = name.indexOf('$') < 0 /** - * Tests if this is a performance-partition of the HEAD table. + * Tests if this is a distribution-partition of the HEAD table, i.e. `foo$12`. * @param name the table name. * @return _true_ if this is a performance-partition of the HEAD table. */ @JvmStatic @JsStatic - fun isHeadPartition(name: String): Boolean = name.indexOf(PG_PART) > 0 // {name}$a??? - - /** - * Tests if this is any DELETED table. - * @param name the table name. - * @return _true_ if this is any DELETED table. - */ - @JvmStatic - @JsStatic - fun isAnyDeleted(name: String): Boolean = isDeleted(name) || isDeletedPartition(name) - - /** - * Tests if this is the root DELETED table. - * @param name the table name. - * @return _true_ if this is the root DELETED table. - */ - @JvmStatic - @JsStatic - fun isDeleted(name: String): Boolean = name.endsWith(PG_DEL) // {name}$del + fun isHeadDistributionPartition(name: String): Boolean = name.indexOf('$') > 0 + && !name.contains("\$hst") + && !name.contains("\$meta") /** - * Tests if this is a performance-partition of the DELETED table. - * @param name the table name. - * @return _true_ if this is a performance-partition of the DELETED table. - */ - @JvmStatic - @JsStatic - fun isDeletedPartition(name: String): Boolean = name.indexOf("${PG_DEL}${PG_PART}") > 0 // {name}$del$a??? - - /** - * Tests if this is the META table. + * Tests if this is the META table, i.e. `foo$meta`. * @param name the table name. * @return _true_ if this is the META table. */ @JvmStatic @JsStatic - fun isMeta(name: String): Boolean = name.endsWith(PG_META) // {name}$meta + fun isMeta(name: String): Boolean = name.endsWith("\$meta") /** * Tests if this is any HISTORY table. @@ -139,7 +81,7 @@ open class PgTable( */ @JvmStatic @JsStatic - fun isAnyHistory(name: String): Boolean = name.indexOf(PG_HST) > 0 // {name}$hst... + fun isAnyHistory(name: String): Boolean = name.indexOf("\$hst") > 0 /** * Tests if this is the root HISTORY table. @@ -148,48 +90,27 @@ open class PgTable( */ @JvmStatic @JsStatic - fun isHistory(name: String): Boolean = name.endsWith("${PG_S}${PG_HST}") // {name}$hst + fun isHistory(name: String): Boolean = name.endsWith("\$hst") /** - * Tests if this is a year-partition of HISTORY, but not a performance-partition. - * New naming: `{collection}$hst$` at end (no `$p` after). + * Tests if this is a partition of HISTORY, but not a distribution-partition, i.e. `foo$hst$2026` * @param name the table name. - * @return _true_ if this is a year-partition of HISTORY. + * @return _true_ if this is a partition of HISTORY. */ @JvmStatic @JsStatic - fun isHistoryYear(name: String): Boolean { - val hstIdx = name.indexOf(PG_HST) - if (hstIdx < 0) return false - val after = hstIdx + PG_HST.length - if (after >= name.length || name[after] != '$') return false - val keyStart = after + 1 - val partIdx = name.indexOf(PG_PART, keyStart) - val keyEnd = if (partIdx < 0) name.length else partIdx - if (keyEnd <= keyStart) return false - return name.substring(keyStart, keyEnd).all { it.isDigit() } && partIdx < 0 - } + fun isHistoryPartition(name: String): Boolean = name.indexOf("\$hst") > 0 + && name.count { it.code == '$'.code } == 2 /** - * Tests if this is a performance-partition of a HISTORY year-partition. - * New naming: `{collection}$hst$$p`. + * Tests if this is a distribution-partition of a HISTORY partition, i.e. `foo$hst$2026$1` * @param name the table name. * @return _true_ if this is a performance-partition of a HISTORY year-partition. */ @JvmStatic @JsStatic - fun isHistoryPartition(name: String): Boolean { - val hstIdx = name.indexOf(PG_HST) - if (hstIdx < 0) return false - val after = hstIdx + PG_HST.length - if (after >= name.length || name[after] != '$') return false - val keyStart = after + 1 - val partIdx = name.indexOf(PG_PART, keyStart) - if (partIdx < 0) return false - val keyEnd = partIdx - if (keyEnd <= keyStart) return false - return name.substring(keyStart, keyEnd).all { it.isDigit() } - } + fun isHistoryDistributionPartition(name: String): Boolean = name.indexOf("\$hst") > 0 + && name.count { it.code == '$'.code } == 3 /** * An indicator if this is an internal Naksha collection. Very special rules apply to these tables. @@ -198,263 +119,67 @@ open class PgTable( */ @JvmStatic @JsStatic - fun isInternal(name: String): Boolean = name.startsWith(PG_INTERNAL_PREFIX) + fun isInternal(name: String): Boolean = name.startsWith("naksha~") } + /** + * The `TOAST` management code is triggered only when a row value to be stored in a table is wider than `TOAST_TUPLE_THRESHOLD` bytes (normally 2 kB). The `TOAST` code will compress and/or move field values out-of-line until the row value is shorter than `TOAST_TUPLE_TARGET` bytes (also normally 2 kB, adjustable) or no more gains can be had. During an `UPDATE` operation, values of unchanged fields are normally preserved as-is; so an `UPDATE` of a row with out-of-line values incurs no `TOAST` costs if none of the out-of-line values change. + * + * **Note**: The value of `TOAST_TUPLE_THRESHOLD` is actually via compile switch fixed to 2 kB. + */ + val toast_tuple_target: Int + get() = 2048 // collection.catalog.storage.adminCatalog.maxTupleSize + /** * The table identifier, optionally quoted in double quotes. */ @JvmField val quotedName: String = quoteIdent(name) + @JvmField + val distributionPartitions: Array = if (collection.partitions <= 1) emptyArray() + else if (this is PgHistoryPartition) Array(collection.partitions) { PgDistributionPartition(this, it) } + else if (this is PgHeadTable) Array(collection.partitions) { PgDistributionPartition(this, it) } + else emptyArray() + /** - * Generate [CREATE_SQL] and [TABLESPACE]. - * - * Actually, history is always partitioned by year, all other tables are optionally performance partitioned. + * Generates the `CREATE_TABLE` string and the `TABLESPACE` string. Use like: + * ```kotlin + * val (CREATE_TABLE, TABLESPACE) = CREATE_TABLE_and_TABLESPACE() + * ``` + * The returned `CREATE_TABLE` is something like `"CREATE TABLE IF NOT EXISTS "`, the `TABLESPACE` a string like `""` or `" TABLESPACE foo"`, so that the tablespace can be appended to the end of a table creation statement, and the create-table string is the prefix for the table creation. + * @return the `CREATE_TABLE` string and the `TABLESPACE` string. + * @since 3.0 */ - private fun doInit(): Pair { - // About TOAST_TUPLE_TARGET - // - // Each page in PostgresQL looks like: - // Page header: ~24 bytes - // ItemId array: 4 bytes per tuple - // Line pointer: part of ItemId array - // Tuple header: 23 bytes (for a normal heap tuple) - // NULL bitmap: variable (~1 byte per 8 columns) - // Actual data: variable - // - // Now, PostgresQL maximum page size is 32768 (configurable at compile time), therefore, setting TOAST_TUPLE_TARGET to 32767 - // will ensure that whatever size a page is, PostgresQL will try to insert the row completely, before falling back to TOAST ! - val map = collection.map; - val toast_tuple_target = if (map is PgAdminMap) map.maxTupleSize else map.storage.adminMap.maxTupleSize; - - // Copy to stack, makes possible for the compiler to remember when values are not null! - val shift = collection.head.shift - val partitionCount = this.partitionCount - val partitionColumn = this.partitionByColumn - val partitionValue = partitionOfValue - val parentTable = partitionOfTable - val superTable: PgTable? = parentTable?.partitionOfTable - - // Verify state, if this is a child table. - if (parentTable != null) { - require(parentTable.partitionByColumn != null) { - "The table '${name}' is a partition of '${parentTable.name}', but the parent does not declare 'partitionBy'" - } - when (parentTable.partitionByColumn) { - PgColumn.fn -> { - require(partitionValue >= 0 && partitionValue < parentTable.partitionCount) { - """The table '$name' is a partition of '${parentTable.name}', but does not declare a valid 'partitionOfValue' (0 to ${parentTable.partitionCount}): $partitionValue""" - } - } - - PgColumn.next_version -> { - require(partitionValue > 0) { - """The table '$name' is a partition of '${parentTable.name}', but does not declare a valid 'partitionOfValue' (expect a positive integer from next_version >> $shift): $partitionValue""" - } - } - - else -> throw IllegalArgumentException( - """The table '$name' is partitioned by invalid column: ${parentTable.partitionByColumn} (must be ${PgColumn.fn.name} or ${PgColumn.next_version.name})""" - ) - } - } - - // If this table is partitioned. - if (partitionColumn != null) { - when (partitionColumn) { - // The children are performance tables, partitioned by partition-number. - PgColumn.fn -> { - require(partitionCount in 2..1000) { "Invalid partition-count, expect 2 .. 1000, found : $partitionCount" } - } - // The children are yearly tables, either in transaction table or history table, partition by year. - PgColumn.next_version -> { - require(partitionCount == 0) { "Invalid partition-count, expect 0, found : $partitionCount" } - } - - else -> throw IllegalArgumentException("Unsupported partitionByColumn: '$partitionColumn'") - } - } else { - // This table is not partitioned, so we need to create it. - require(partitionCount == 0) { - "No partitioning, but partitionCount ($partitionCount) given for table '$name'" - } - } - - val storage = collection.map.storage - val CREATE_TABLE: String - val TABLESPACE: String - when (storageClass) { - PgStorageClass.Ephemeral -> { - CREATE_TABLE = "CREATE TABLE IF NOT EXISTS " - TABLESPACE = - if (storage.adminMap.ephemeralTableSpace != null) " TABLESPACE ${storage.adminMap.ephemeralTableSpace}" else "" - } - - PgStorageClass.Brittle -> { - CREATE_TABLE = "CREATE UNLOGGED TABLE IF NOT EXISTS " - TABLESPACE = if (storage.adminMap.brittleTableSpace != null) " TABLESPACE ${storage.adminMap.brittleTableSpace}" else "" - } - - PgStorageClass.Temporary -> { - CREATE_TABLE = "CREATE UNLOGGED TABLE IF NOT EXISTS " - TABLESPACE = if (storage.adminMap.tempTableSpace != null) " TABLESPACE ${storage.adminMap.tempTableSpace}" else "" - } - - else -> { - CREATE_TABLE = "CREATE TABLE IF NOT EXISTS " - TABLESPACE = "" - } - } - - // If this table is a child table (a partition). - if (parentTable != null) { - // If this table has a parent and a super-table, then this must be a performance partition of history, - // as this is the only situation where we have two levels above us, we have: - // HISTORY -> YEARLY -> PARTITION (this) - if (superTable != null) { - val superPartValue = parentTable.partitionOfValue - if (superPartValue <= 0) { - throw illegalState("The table '${parentTable.name}' is a yearly partition with an illegal partition key: $superPartValue") - } - val parentPartCount = parentTable.partitionCount - if (partitionValue !in 0 ..< parentPartCount) { - throw illegalState("The table '${name}' is performance-partitioned with an partition-number being illegal: $partitionValue; expected 0 till $parentPartCount") - } - // HISTORY year + perf-partition leaf: PK includes (fn, version, next_version) because next_version is the year-partition key. - val SQL = """$CREATE_TABLE $quotedName PARTITION OF ${parentTable.quotedName} ( - CONSTRAINT ${quoteIdent(PgIndex.fn_pkey.id(this))} PRIMARY KEY (${PgColumn.fn}, ${PgColumn.version}, ${PgColumn.next_version}), - CONSTRAINT ${quoteIdent(name + PG_TN_NEXT_CONSTRAINT)} CHECK (next_version IS NOT NULL AND ((next_version >> $shift)::int4)=${superPartValue}), - CONSTRAINT ${quoteIdent(name + PG_ID_CONSTRAINT)} CHECK (((fn & 65535)::int4 % $parentPartCount)=$partitionValue) -) FOR VALUES FROM ($partitionValue) TO (${partitionValue+1}) -WITH (fillfactor=100,toast_tuple_target=$toast_tuple_target) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) - } - - // If the parent table is partitioned by year, but there is no super table, this is a yearly table. - if (parentTable.partitionByColumn == PgColumn.next_version) { - if (partitionValue <= 0) { - throw illegalState("The table '$name' is a yearly partition with an illegal partition key: $partitionValue") - } - - // If this table is further partitioned, this must be part of history. - // HISTORY -> YEARLY (this) -> PARTITION - if (partitionColumn != null) { - if (partitionColumn != PgColumn.fn) { - throw illegalState("The history table '$name' must be partitioned only by `fn`, but ${partitionColumn.name} was used!") - } - val SQL = """$CREATE_TABLE $quotedName PARTITION OF ${parentTable.quotedName} ( - CONSTRAINT ${quoteIdent(name + PG_TN_NEXT_CONSTRAINT)} CHECK (next_version IS NOT NULL AND ((next_version >> $shift)::int4)=$partitionValue) -) FOR VALUES FROM ($partitionValue) TO (${partitionValue+1}) -PARTITION BY RANGE (((fn & 65535)::int4 % $partitionCount)) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) - } - - // HISTORY/TX yearly leaf (no further partitioning). PK includes (fn, version, next_version). - val SQL = """$CREATE_TABLE $quotedName PARTITION OF ${parentTable.quotedName} ( - CONSTRAINT ${quoteIdent(PgIndex.fn_pkey.id(this))} PRIMARY KEY (${PgColumn.fn}, ${PgColumn.version}, ${PgColumn.next_version}), - CONSTRAINT ${quoteIdent(name + PG_TN_NEXT_CONSTRAINT)} CHECK (next_version IS NOT NULL AND ((next_version >> $shift)::int4)=$partitionValue) -) FOR VALUES FROM ($partitionValue) TO (${partitionValue+1}) -WITH (fillfactor=100,toast_tuple_target=$toast_tuple_target) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) - } - - // If parent is partitioned by `fn`, then this is a performance partition either of HEAD or DELETED: - // HEAD/DELETED -> PARTITION (this) - if (parentTable.partitionByColumn == PgColumn.fn) { - if (partitionColumn != null) { - throw illegalState("The performance partition '$name' must not be partitioned further, but is by $partitionColumn") - } - val parentPartCount = parentTable.partitionCount - if (partitionValue !in 0 ..< parentPartCount) { - throw illegalState("The table '${name}' is performance-partitioned with an partition-number being illegal: $partitionValue; expected 0 till $parentPartCount") - } - val nextVersionConstraint = if (isDeleted(parentTable.name)) - " CONSTRAINT ${quoteIdent(name + PG_TN_NEXT_CONSTRAINT)} CHECK (next_version = version),\n" - else "" - val SQL = """$CREATE_TABLE $quotedName PARTITION OF ${parentTable.quotedName} ( - CONSTRAINT ${quoteIdent(PgIndex.fn_pkey.id(this))} PRIMARY KEY (${PgColumn.fn}) INCLUDE (${PgColumn.version}), -$nextVersionConstraint CONSTRAINT ${quoteIdent(name + PG_ID_CONSTRAINT)} CHECK (((fn & 65535)::int4 % $parentPartCount)=$partitionValue) -) FOR VALUES FROM ($partitionValue) TO (${partitionValue+1}) -WITH (fillfactor=100,toast_tuple_target=$toast_tuple_target) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) - } - - // This is an unsupported setup! - throw illegalState("Table '${name}' is partition by '${parentTable.partitionByColumn}', this is not supported") - } - // We do not have a parent table, this is a root table. - - // This is table is partitioned by year, it must be either transaction or history! - if (partitionColumn == PgColumn.next_version) { - // TX (this) -> YEARLY - // HISTORY (this) -> YEARLY - // HISTORY (this) -> YEARLY -> PARTITION - val columns = PgColumn.allColumns - val SQL = """$CREATE_TABLE $quotedName ( -${columnDefinitions(columns)}, -CONSTRAINT ${quoteIdent(name + PG_TN_NEXT_CONSTRAINT)} CHECK (next_version IS NOT NULL), -CONSTRAINT ${quoteIdent(name + PG_ID_CONSTRAINT)} CHECK ((fn < 0 AND id IS NOT NULL) OR (fn >= 0 AND id IS NULL)) -) PARTITION BY RANGE (((next_version >> $shift)::int4)) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) + @Suppress("FunctionName") + protected fun CREATE_TABLE_and_TABLESPACE(): Pair { + val adminCatalog = collection.catalog.storage.adminCatalog + return when (collection.storageClass) { + PgStorageClass.Ephemeral -> Pair( + "CREATE TABLE IF NOT EXISTS ", + if (adminCatalog.ephemeralTableSpace != null) " TABLESPACE ${adminCatalog.ephemeralTableSpace}" else "" + ) + + PgStorageClass.Brittle -> Pair( + "CREATE UNLOGGED TABLE IF NOT EXISTS ", + if (adminCatalog.brittleTableSpace != null) " TABLESPACE ${adminCatalog.brittleTableSpace}" else "" + ) + + PgStorageClass.Temporary -> Pair( + "CREATE UNLOGGED TABLE IF NOT EXISTS ", + if (adminCatalog.tempTableSpace != null) " TABLESPACE ${adminCatalog.tempTableSpace}" else "" + ) + + else -> Pair("CREATE TABLE IF NOT EXISTS ", "") } - - // If the root table is not partitioned. - if (partitionColumn == null) { - // HEAD, META, or DELETED. One row per feature; PK = (fn) INCLUDE (version). - // All tables always use the full head column set; custom members are appended via - // columnDefinitions(). - val columns = if (isAnyHead(name) || isMeta(name)) PgColumn.headColumns else PgColumn.allColumns - val SQL = """$CREATE_TABLE $quotedName ( -${columnDefinitions(columns)}, -CONSTRAINT ${quoteIdent(PgIndex.fn_pkey.id(this))} PRIMARY KEY (${PgColumn.fn}) INCLUDE (${PgColumn.version}), -CONSTRAINT ${quoteIdent(name + PG_ID_CONSTRAINT)} CHECK ((fn < 0 AND id IS NOT NULL) OR (fn >= 0 AND id IS NULL)) -) -WITH (fillfactor=100,toast_tuple_target=$toast_tuple_target) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) - } - - // If performance partitioned and not META. - if (partitionColumn == PgColumn.fn && !isMeta(name)) { - // HEAD (this) -> PARTITION - // DELETED (this) -> PARTITION - val columns = if (isDeleted(name)) PgColumn.allColumns else PgColumn.headColumns - val nextVersionConstraint = if (isDeleted(name)) - "CONSTRAINT ${quoteIdent(name + PG_TN_NEXT_CONSTRAINT)} CHECK (next_version = version)" - else "" - val SQL = """$CREATE_TABLE $quotedName ( -${columnDefinitions(columns)}${if (nextVersionConstraint.isNotEmpty()) ",\n$nextVersionConstraint" else ""}, -CONSTRAINT ${quoteIdent(name + PG_ID_CONSTRAINT)} CHECK ((fn < 0 AND id IS NOT NULL) OR (fn >= 0 AND id IS NULL)) -) PARTITION BY RANGE (((fn & 65535)::int4 % $partitionCount)) -$TABLESPACE""" - return Pair(SQL, TABLESPACE) - } - - // Illegal partitioning. - throw illegalState("The table '$name' is partitioned by $partitionColumn, this is not supported") } /** * The SQL code needed to create the table. * @return the SQL code needed to create the table. */ - @JvmField - internal val CREATE_SQL: String? - - @JvmField - internal val TABLESPACE: String? - - init { - val (CREATE_SQL, TABLESPACE) = doInit() - this.CREATE_SQL = CREATE_SQL - this.TABLESPACE = TABLESPACE - } + @Suppress("FunctionName") + abstract fun CREATE_SQL(): String /** * All existing and declared indices. @@ -462,59 +187,11 @@ $TABLESPACE""" var indices: List = emptyList() internal set - /** - * If this table is partitioned by year. - */ - @JvmField - val hasYearPartitions: Boolean = partitionByColumn == PgColumn.next_version - - /** - * If this table is performance partitioned, so features are stored based upon their ID. - */ - @JvmField - val hasTnPartitions: Boolean = (partitionByColumn == PgColumn.fn) && partitionCount >= 2 - /** * Create the table and its partitions. */ internal open fun create(conn: PgConnection) { - if (CREATE_SQL != null) conn.execute(CREATE_SQL).close() - } - - /** - * Add any custom member columns that are declared in [NakshaCollection.members] but do not yet - * exist in the physical table. This is the migration path for collections that have gained new - * members after their initial creation. - * - * For each declared member whose name is not already a known [PgColumn], issues: - * ```sql - * ALTER TABLE ADD COLUMN IF NOT EXISTS - * ``` - * For known built-in [PgColumn] names the same statement is issued so that existing tables - * gain optional columns added in later releases (e.g. `pn`, `pt`, `gv`). - */ - internal open fun addMissingCustomColumns(conn: PgConnection) { - // Always ensure all standard head columns are present (covers tables created with the old - // lean schema that predates the full-schema approach). - for (col in PgColumn.headColumns) { - conn.execute("ALTER TABLE $quotedName ADD COLUMN IF NOT EXISTS ${col.sqlDefinition}").close() - } - // Also add any explicitly declared members that go beyond the standard set. - val members = collection.head.members ?: return - val knownCols = PgColumn.allColumnsByName - val standardSet = PgColumn.headColumns.toSet() - for (m in members) { - if (m == null) continue - val knownPgCol = knownCols[m.name] - // For known PgColumn entries not in the standard headColumns (e.g. pn, pt, gv), - // use the PgColumn sql definition; for truly custom members use the member type mapping. - val colDef = when { - knownPgCol != null && knownPgCol !in standardSet -> knownPgCol.sqlDefinition - knownPgCol != null -> continue // already in headColumns loop above - else -> PgCustomMemberValues.sqlDefinitionFor(m) - } - conn.execute("ALTER TABLE $quotedName ADD COLUMN IF NOT EXISTS $colDef").close() - } + conn.execute(CREATE_SQL()).close() } /** @@ -556,41 +233,13 @@ $TABLESPACE""" */ open fun dropIndex(conn: PgConnection, index: PgIndex) { if (indices.contains(index)) { - index.drop(conn, this) + index.drop(conn, name) indices = indices - index } } /** - * Returns the SQL fragment with extra column definitions for user-declared [members][naksha.model.objects.NakshaCollection.members]. - * - * The result is empty when there are no members, otherwise it is comma-prefixed and ready to be appended to the built-in column list in `CREATE TABLE`. - */ - internal fun extraColumnDefinitions(): String { - val members = collection.head.members ?: return "" - if (members.isEmpty()) return "" - val knownCols = PgColumn.allColumnsByName - val standardSet = PgColumn.headColumns.toSet() - val sb = StringBuilder() - for (member in members) { - if (member == null) continue - val knownPgCol = knownCols[member.name] - val colDef = when { - // Known PgColumn not in the standard head set (e.g. pn, pt, gv): use the canonical definition. - knownPgCol != null && knownPgCol !in standardSet -> knownPgCol.sqlDefinition - // Standard head columns are already in baseColumns; skip to avoid duplicates. - knownPgCol != null -> continue - // Truly custom member: generate from type mapping. - else -> PgCustomMemberValues.sqlDefinitionFor(member) - } - sb.append(",\n").append(colDef) - } - return sb.toString() - } - - /** - * Maps a [PgType] to the same sort-order bucket used by [PgCustomMemberValues.columnSortOrder], - * so that standard and custom columns can be interleaved into the correct type-alignment group. + * Maps a [PgType] to a sort-order. */ private fun pgTypeSortOrder(type: PgType): Int = when (type) { PgType.INT64 -> 0 // INT64 @@ -601,234 +250,21 @@ $TABLESPACE""" PgType.BOOLEAN -> 6 // BOOLEAN PgType.STRING -> 7 // STRING PgType.BYTE_ARRAY -> 8 // BYTE_ARRAY / SPATIAL - else -> 11 + PgType.JSONB -> 9 // JSONB, aka TAGS and SET + else -> 10 // should not happen! } /** - * Builds the full comma-separated column-definition block for a `CREATE TABLE` statement, merging - * [baseColumns] with any custom/SPECIAL members declared on the collection. - * - * Extra members (SPECIAL like `pn`/`pt`/`gv`, plus truly custom ones) are inserted **within - * their correct type-alignment bucket** — immediately after the last standard column of the same - * type group. The result follows the canonical layout: - * - * ``` - * [INT64 std cols] [INT64 extras] [FLOAT64 std] [FLOAT64 extras] ... [BYTE_ARRAY std] [BYTE_ARRAY extras] [TAGS extras] - * ``` - * - * When [NakshaCollection.members] is `null` (backward-compatible mode) no custom columns exist and - * [baseColumns] are emitted as-is. + * Builds the full comma-separated column-definition block for a `CREATE TABLE` statement. The reads the [PgCollection]. + * @return the SQL column declarations to be used inside a `CREATE TABLE` statement. */ - internal fun columnDefinitions(baseColumns: List): String { - val members = collection.head.members - if (members.isNullOrEmpty()) { - return baseColumns.joinToString(",\n") { it.sqlDefinition } - } - val knownCols = PgColumn.allColumnsByName - val standardSet = baseColumns.toSet() - - // Collect extra (SPECIAL + custom) column definitions, grouped by sort-order bucket. - // Bucket → ordered list of SQL column definition strings. - val extrasByBucket = mutableMapOf>() - for (m in members) { - if (m == null) continue - val knownPgCol = knownCols[m.name] - val colDef = when { - knownPgCol != null && knownPgCol !in standardSet -> knownPgCol.sqlDefinition - knownPgCol != null -> continue // already in baseColumns — skip - else -> PgCustomMemberValues.sqlDefinitionFor(m) - } - val bucket = PgCustomMemberValues.columnSortOrder(m.dataType) - extrasByBucket.getOrPut(bucket) { mutableListOf() }.add(colDef) - } - - if (extrasByBucket.isEmpty()) { - return baseColumns.joinToString(",\n") { it.sqlDefinition } - } - - // Determine the index of the last standard column in each bucket. - val lastIndexForBucket = mutableMapOf() - for ((idx, col) in baseColumns.withIndex()) { - val bucket = pgTypeSortOrder(col.type) - lastIndexForBucket[bucket] = idx - } - // Also build a sorted list of standard bucket values for gap detection. - val standardBuckets = lastIndexForBucket.keys.sorted() - + internal fun columnDefinitions(): String { val sb = StringBuilder() - for ((idx, col) in baseColumns.withIndex()) { + for (column in collection.columns) { if (sb.isNotEmpty()) sb.append(",\n") - sb.append(col.sqlDefinition) - // After the last standard column of this bucket, flush: - // 1. Extras for this exact bucket. - // 2. Extras for any intermediate buckets that have no standard columns and fall - // between this bucket and the next standard bucket (or end-of-list). - val bucket = pgTypeSortOrder(col.type) - if (lastIndexForBucket[bucket] == idx) { - // Flush this bucket's extras. - val extras = extrasByBucket.remove(bucket) - if (extras != null) for (def in extras) sb.append(",\n").append(def) - // Determine the next standard bucket (if any). - val nextStdBucket = standardBuckets.firstOrNull { it > bucket } - // Flush all intermediate extra-only buckets that belong before the next standard bucket. - for (extraBucket in extrasByBucket.keys.sorted()) { - if (nextStdBucket != null && extraBucket >= nextStdBucket) break - val gapExtras = extrasByBucket.remove(extraBucket) ?: continue - for (def in gapExtras) sb.append(",\n").append(def) - } - } - } - // Flush any remaining extras (buckets beyond the last standard bucket, e.g. TAGS). - for (bucket in extrasByBucket.keys.sorted()) { - for (def in extrasByBucket[bucket]!!) sb.append(",\n").append(def) + sb.append(column.sql) } return sb.toString() } - - /** - * Returns the unique identifier for a user-defined index on this table. - * - * The result follows the pattern `{table-name}$ci_{index-name}` and is truncated to 63 bytes (Postgres identifier limit). - */ - internal fun customIndexId(indexName: String): String { - val id = "${name}${PG_CUSTOM_IDX}${indexName}" - return if (id.length > 63) id.substring(0, 63) else id - } - - /** - * Creates a [CustomIndex][naksha.model.objects.CustomIndex] on this table. - * - * Internal naming follows `{table-name}$ci_{index.name}`; truncated to Postgres's 63-byte identifier limit. - * @param conn the connection to use to execute the creation. - * @param index the index to create. - */ - open fun createCustomIndex(conn: PgConnection, index: Index) { - val sql = buildCustomIndexSql(index) ?: return - conn.execute(sql).close() - } - - /** - * Drops a custom [Index] from this table. - */ - open fun dropCustomIndex(conn: PgConnection, indexName: String) { - val id = customIndexId(indexName) - conn.execute("DROP INDEX IF EXISTS ${quoteIdent(id)} CASCADE").close() - } - - private fun buildCustomIndexSql(index: Index): String? { - val on = index.on - if (on.isEmpty()) return null - val id = quoteIdent(customIndexId(index.name)) - val unique = if (index.unique) "UNIQUE INDEX" else "INDEX " - val fillFactor = if (isVolatile) "WITH (deduplicate_items=OFF, fillfactor=80)" else "WITH (deduplicate_items=OFF, fillfactor=100)" - val members = collection.head.members - val firstCol = on[0] ?: return null - return when (index.type) { - IndexType.BTREE -> { - val cols = on.filterNotNull().joinToString(", ") { col -> - val pgIdent = quoteIdent(physicalColumnName(col, members)) - val opclass = if (isTextColumn(col, members)) " text_pattern_ops" else "" - "$pgIdent$opclass DESC" - } - val include = index.include?.takeIf { !it.isEmpty() }?.let { inc -> - " INCLUDE (${inc.filterNotNull().joinToString(", ") { quoteIdent(physicalColumnName(it, members)) }})" - } ?: "" - """ -CREATE $unique IF NOT EXISTS $id ON ${quotedName} -USING btree ($cols)$include -$fillFactor ${TABLESPACE};""".trim() - } - IndexType.SPATIAL -> { - if (!isSpatialColumn(firstCol, members)) { - throw naksha.model.illegalArg("SPATIAL index '${index.name}' must target a member of dataType SPATIAL (column '$firstCol' is not)") - } - val cCol = quoteIdent(physicalColumnName(firstCol, members)) - """ -CREATE INDEX IF NOT EXISTS $id ON ${quotedName} -USING gist (naksha_2d($cCol)) -WITH (fillfactor=${if (isVolatile) 80 else 100}) ${TABLESPACE} -WHERE naksha_2d($cCol) IS NOT NULL;""".trim() - } - IndexType.TAGS -> { - if (!isFlatMapColumn(firstCol, members)) { - throw naksha.model.illegalArg("TAGS custom index '${index.name}' must target a member of dataType TAGS or TAGS_FROM_ARRAY") - } - val pgIdent = quoteIdent(physicalColumnName(firstCol, members)) - """ -CREATE INDEX IF NOT EXISTS $id ON ${quotedName} -USING gin ($pgIdent) -${TABLESPACE};""".trim() - } - IndexType.SET -> { - if (!isSetColumn(firstCol, members)) { - throw naksha.model.illegalArg("SET custom index '${index.name}' must target a member of dataType SET") - } - val pgIdent = quoteIdent(physicalColumnName(firstCol, members)) - """ -CREATE INDEX IF NOT EXISTS $id ON ${quotedName} -USING gin ($pgIdent) -${TABLESPACE};""".trim() - } - else -> null - } - } - - private fun physicalColumnName(name: String, members: MemberList?): String { - if (members != null) { - for (m in members) { - if (m != null && m.name == name) return PgCustomMemberValues.pgColumnName(name) - } - } - return name - } - - private fun isTextColumn(name: String, members: MemberList?): Boolean { - if (members != null) { - for (m in members) { - if (m != null && m.name == name) { - return m.dataType == MemberType.STRING - } - } - } - if (name == "id" || name == "app_id" || name == "author" || name == "ft" || name == "origin" || name == "target") return true - return false - } - - private fun isFlatMapColumn(name: String, members: MemberList?): Boolean { - val type = memberTypeOf(name, members) ?: return false - return type == MemberType.TAGS || type == MemberType.TAGS_FROM_ARRAY - } - - private fun isSetColumn(name: String, members: MemberList?): Boolean = - memberTypeOf(name, members) == MemberType.SET - - /** - * Resolves the [MemberType] of a member by name: explicitly declared members take precedence, - * otherwise the standard member contract ([StandardMembers.ALL]) is consulted (e.g. the built-in - * `tags` member, which is a [MemberType.SET]). - */ - private fun memberTypeOf(name: String, members: MemberList?): MemberType? { - if (members != null) { - for (m in members) { - if (m != null && m.name == name) return m.dataType - } - } - for (sm in naksha.model.objects.StandardMembers.ALL) { - if (sm.name == name) return sm.dataType - } - return null - } - - private fun isSpatialColumn(name: String, members: MemberList?): Boolean { - // Built-in spatial columns are always valid targets. - if (name == PgColumn.geo.name || name == PgColumn.ref_point.name) return true - // Custom members declared as SPATIAL are valid. - if (members != null) { - for (m in members) { - if (m != null && m.name == name) return m.dataType == MemberType.SPATIAL - } - } - return false - } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgType.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgType.kt index e16ef15a4f..43dd0c9cc0 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgType.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgType.kt @@ -3,7 +3,8 @@ package naksha.psql import naksha.base.JsEnum -import naksha.base.PlatformUtil +import naksha.model.objects.Member +import naksha.model.objects.MemberType import kotlin.js.JsExport import kotlin.js.JsStatic import kotlin.jvm.JvmField @@ -127,7 +128,7 @@ class PgType : JsEnum() { /** * JSONB column type, bound as text (JSON). * - * Used by storages to materialize [naksha.model.objects.MemberType.TAGS], [naksha.model.objects.MemberType.TAGS_FROM_ARRAY] (JSON object), and [naksha.model.objects.MemberType.SET] (JSON array) members. + * Used by storages to materialize [naksha.model.objects.MemberType.TAG_MAP], [naksha.model.objects.MemberType.TAG_MAP_FROM_ARRAY] (JSON object), and [naksha.model.objects.MemberType.TAG_LIST] (JSON array) members. * @since 3.0 */ @JvmField @@ -154,6 +155,54 @@ class PgType : JsEnum() { @JsStatic @JvmStatic fun of(name: String?): PgType? = getDefined(name, PgType::class) + + /** + * Detects the [PgType] of the given value, if it has any. + * @param value the value for which to detect the [PgType] + * @return the matching [PgType] or `null`, if none matches. + */ + @JsStatic + @JvmStatic + fun ofValue(value: Any?): PgType? { + // TODO: Implement detection. + return null + } + + /** + * Returns the database column type to be used for a specific [Member]. + * @param member the [Member] to lookup. + * @return the database column type to be used for a specific [Member]. + * @since 3.0 + */ + @JsStatic + @JvmStatic + fun ofMember(member: Member): PgType = ofMemberType(member.dataType) + + /** + * Returns the database column type to be used for a specific [MemberType]. + * @param memberType the [MemberType] to lookup. + * @return the database column type to be used for a specific [MemberType]. + * @since 3.0 + */ + @JsStatic + @JvmStatic + fun ofMemberType(memberType: MemberType): PgType = when (memberType) { + MemberType.BOOLEAN -> BOOLEAN + MemberType.INT8 -> SHORT + MemberType.INT16 -> SHORT + MemberType.INT32 -> INT + MemberType.INT64 -> INT64 + MemberType.FLOAT32 -> FLOAT + MemberType.FLOAT64 -> DOUBLE + MemberType.STRING -> STRING + MemberType.TAG_MAP -> JSONB + MemberType.TAG_MAP_FROM_ARRAY -> STRING_ARRAY + MemberType.TAG_LIST -> STRING_ARRAY + // MemberType.BYTE_ARRAY -> BYTE_ARRAY + // MemberType.TUPLE_NUMBER -> BYTE_ARRAY + // MemberType.SPATIAL -> BYTE_ARRAY (TWKB) + else -> BYTE_ARRAY + } } @Suppress("NON_EXPORTABLE_TYPE") @@ -183,4 +232,17 @@ class PgType : JsEnum() { */ var childType: PgType? = null private set + + /** + * Converts the given value in this type, so that it can be given to JDBC. + * + * For example, converts a `List` into a `String[]`. + * @param value the value to convert into this type. + * @return the value so that it can be used with JDBC. + */ + fun convertValue(value: Any?): Any? { + // TODO: We need to convert certain values to postgres valid ones + // For lists, we need to convert them into typed arrays. + return value + } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgUtil.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgUtil.kt index b860125afa..eeb38459c2 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgUtil.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgUtil.kt @@ -6,11 +6,11 @@ import naksha.base.* import naksha.geo.SpGeometry import naksha.jbon.* import naksha.model.* -import naksha.model.Naksha.NakshaCompanion.ADMIN_MAP -import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL -import naksha.model.Naksha.NakshaCompanion.BOOKS_COL -import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL -import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL +import naksha.model.Naksha.NakshaCompanion.ADMIN_CATALOG_ID +import naksha.model.Naksha.NakshaCompanion.COLLECTIONS_COL_ID +import naksha.model.Naksha.NakshaCompanion.BOOKS_COL_ID +import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_ID +import naksha.model.Naksha.NakshaCompanion.TRANSACTIONS_COL_ID import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT import naksha.model.objects.NakshaFeature import naksha.psql.PgPlatform.PgPlatformCompanion.quote_ident @@ -32,7 +32,7 @@ class PgUtil private constructor() { */ @JvmField @JsStatic - val ADMIN_MAP_QUOTED = quoteIdent(ADMIN_MAP) + val ADMIN_MAP_QUOTED = quoteIdent(ADMIN_CATALOG_ID) /** * The quoted identifier of the collection in which transactions are stored. @@ -40,7 +40,7 @@ class PgUtil private constructor() { */ @JvmField @JsStatic - val ADMIN_TRANSACTIONS_COL_QUOTED = quoteIdent(TRANSACTIONS_COL) + val ADMIN_TRANSACTIONS_COL_QUOTED = quoteIdent(TRANSACTIONS_COL_ID) /** * The quoted identifier of the virtual catalogs collection to be used in queries. @@ -48,7 +48,7 @@ class PgUtil private constructor() { */ @JvmField @JsStatic - val ADMIN_CATALOGS_COL_QUOTED = quoteIdent(CATALOGS_COL) + val ADMIN_CATALOGS_COL_QUOTED = quoteIdent(CATALOGS_COL_ID) /** * The quoted identifier of the virtual collection in which the books (global JBON2 dictionaries) are stored. @@ -56,7 +56,7 @@ class PgUtil private constructor() { */ @JvmField @JsStatic - val ADMIN_BOOKS_COL_QUOTED = quoteIdent(BOOKS_COL) + val ADMIN_BOOKS_COL_QUOTED = quoteIdent(BOOKS_COL_ID) /** * The quoted identifier of the virtual collections collection to be used in queries. @@ -64,7 +64,7 @@ class PgUtil private constructor() { */ @JvmField @JsStatic - val COLLECTIONS_COL_QUOTED = quoteIdent(COLLECTIONS_COL) + val COLLECTIONS_COL_QUOTED = quoteIdent(COLLECTIONS_COL_ID) /** * Array to query the partition name from the partition number (resolves 0 to "000", 1 to "001", ..., 255 to "256"). @@ -205,98 +205,5 @@ class PgUtil private constructor() { @JsStatic @JvmStatic fun lockId(name: String): Int64 = Fnv1a64.string(Fnv1a64.start(), name) - - /** - * Decode the Naksha feature, auto-detecting the encoding from header bytes. - * @param bytes the bytes to decode. - * @param dictManager the dictionary manager to use for legacy JBON1 decoding; if any. - * @return the Naksha feature. - * @since 3.0.0 - */ - @JsStatic - @JvmStatic - @Deprecated( - message = "Please use Naksha class instead", - replaceWith = ReplaceWith("Naksha.decodeFeature(bytes, dictManager)"), - level = DeprecationLevel.WARNING - ) - fun decodeFeature(bytes: ByteArray?, dictManager: IDictManager? = null): NakshaFeature? = Naksha.decodeFeature(bytes, dictManager) - - /** - * Encodes the given [NakshaFeature] into bytes. - * @param feature the feature to encode. - * @param encoding the feature encoding to use. - * @param dict the dictionary to use for encoding; if any. - * @return the encoded feature. - * @since 3.0.0 - */ - @JsStatic - @JvmStatic - @Deprecated( - message = "Please use Naksha class instead", - replaceWith = ReplaceWith("Naksha.encodeFeature(feature, encoding, dict)"), - level = DeprecationLevel.WARNING - ) - fun encodeFeature(feature: NakshaFeature?, encoding: DataEncoding, dict: JbDictionary? = null): ByteArray? = Naksha.encodeFeature(feature, encoding, dict) - - /** - * Decode the Naksha tags from their raw `jsonb` text form. - * @param json the JSON text to decode (value of the `tags` `jsonb` column). - * @return the Naksha tags. - * @since 3.0.0 - */ - @JsStatic - @JvmStatic - @Deprecated( - message = "Please use Naksha class instead", - replaceWith = ReplaceWith("Naksha.decodeTags(json)"), - level = DeprecationLevel.WARNING - ) - fun decodeTags(json: String?): TagMap? = Naksha.decodeTags(json) - - /** - * Encodes the given tags into raw `jsonb` text. - * @param tags the tags to encode. - * @return the JSON text representation. - * @since 3.0.0 - */ - @JsStatic - @JvmStatic - @Deprecated( - message = "Please use Naksha class instead", - replaceWith = ReplaceWith("Naksha.encodeTags(tags)"), - level = DeprecationLevel.WARNING - ) - fun encodeTags(tags: TagMap?): String? = Naksha.encodeTags(tags) - - /** - * Decode a GeoJSON geometry from `TWKB` encoded bytes. All Naksha geometries are stored as raw TWKB. - * @param bytes the bytes to decode. - * @return the geometry. - * @since 3.0.0 - */ - @JsStatic - @JvmStatic - @Deprecated( - message = "Please use Naksha class instead", - replaceWith = ReplaceWith("Naksha.decodeGeometry(bytes)"), - level = DeprecationLevel.WARNING - ) - fun decodeGeometry(bytes: ByteArray?): SpGeometry? = Naksha.decodeGeometry(bytes) - - /** - * Encodes the given GeoJSON geometry into `TWKB` bytes. All Naksha geometries are stored as raw TWKB. - * @param geometry the geometry to encode. - * @return the encoded GeoJSON geometry. - * @since 3.0.0 - */ - @JsStatic - @JvmStatic - @Deprecated( - message = "Please use Naksha class instead", - replaceWith = ReplaceWith("Naksha.encodeGeometry(geometry)"), - level = DeprecationLevel.WARNING - ) - fun encodeGeometry(geometry: SpGeometry?): ByteArray? = Naksha.encodeGeometry(geometry) } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWrite.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWrite.kt index 4c9c20e246..83cabd4554 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWrite.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWrite.kt @@ -3,7 +3,7 @@ package naksha.psql import naksha.model.* import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature -import naksha.model.objects.NakshaMap +import naksha.model.objects.NakshaCatalog import naksha.model.request.Write import naksha.model.request.WriteOp @@ -18,17 +18,17 @@ internal data class PgWrite(val original: Write, val i: Int) { /** * The map into which to write. * - * - If a map is modified, this is [Naksha.ADMIN_MAP][naksha.model.Naksha.ADMIN_MAP], [asPgMap] and [asNakshaMap] will be set. - * - If a collection is modified, this is the map in which [Naksha.COLLECTIONS_COL][naksha.model.Naksha.COLLECTIONS_COL] is located, [asPgCollection] and [asNakshaCollection] will be set. + * - If a map is modified, this is [Naksha.ADMIN_MAP][naksha.model.Naksha.ADMIN_CATALOG_ID], [asPgCatalog] and [asNakshaMap] will be set. + * - If a collection is modified, this is the map in which [Naksha.COLLECTIONS_COL][naksha.model.Naksha.COLLECTIONS_COL_ID] is located, [asPgCollection] and [asNakshaCollection] will be set. * @since 3.0 */ - lateinit var map: PgMap + lateinit var catalog: PgCatalog /** * The collection into which to write. * - * - If a map is modified, this is [Naksha.CATALOGS_COL][naksha.model.Naksha.CATALOGS_COL], [asPgMap] and [asNakshaMap] will be set. - * - If a collection is modified, this is [Naksha.COLLECTIONS_COL][naksha.model.Naksha.COLLECTIONS_COL], [asPgCollection] and [asNakshaCollection] will be set. + * - If a map is modified, this is [Naksha.CATALOGS_COL][naksha.model.Naksha.CATALOGS_COL_ID], [asPgCatalog] and [asNakshaMap] will be set. + * - If a collection is modified, this is [Naksha.COLLECTIONS_COL][naksha.model.Naksha.COLLECTIONS_COL_ID], [asPgCollection] and [asNakshaCollection] will be set. * @since 3.0 */ lateinit var collection: PgCollection @@ -45,9 +45,9 @@ internal data class PgWrite(val original: Write, val i: Int) { * @since 3.0 */ var action: Action = when (original.op) { - WriteOp.CREATE, WriteOp.UPSERT -> Action.CREATED - WriteOp.UPDATE -> Action.UPDATED - WriteOp.DELETE, WriteOp.PURGE -> Action.DELETED + WriteOp.CREATE, WriteOp.UPSERT -> Action.CREATE + WriteOp.UPDATE -> Action.UPDATE + WriteOp.DELETE, WriteOp.PURGE -> Action.DELETE else -> Action.VERSION } @@ -90,20 +90,13 @@ internal data class PgWrite(val original: Write, val i: Int) { val partition: Int get() = if (collection.partitions > 1) partitionNumber % collection.partitions else -1 - /** - * The attachment from the [Write] instruction, can be [Write.UNDEFINED]. If the attachment is [Write.UNDEFINED], an existing attachment should be retained. - * @since 3.0 - */ - val attachment: ByteArray? - get() = original.attachment - /** * If the operation is atomic, the version in which the _HEAD_ is expected to be; otherwise `null`. * @since 3.0 */ val version: Version? get() = if (original.atomic && op != WriteOp.CREATE && op != WriteOp.UPSERT) - original.version ?: original.tupleNumber?.version + original.version ?: Version(original.tupleNumber?.version!!) else null @@ -115,27 +108,27 @@ internal data class PgWrite(val original: Write, val i: Int) { */ var tuple: Tuple? = null - val isMapModification: Boolean + val isCatalogModification: Boolean get() = original.isMapModification() val isCollectionModification: Boolean get() = original.isCollectionModification() val isTransactionModification: Boolean - get() = Naksha.ADMIN_MAP == map.id && Naksha.TRANSACTIONS_COL == collection.id + get() = Naksha.ADMIN_CATALOG_ID == catalog.id && Naksha.TRANSACTIONS_COL_ID == collection.id // This variant differs from write.isFeatureModification, because it includes dictionaries, which are just features for us! val isFeatureModification: Boolean - get() = !isTransactionModification && !isMapModification && !isCollectionModification + get() = !isTransactionModification && !isCatalogModification && !isCollectionModification /** - * If the feature is a map, the [PgMap] representation. + * If the feature is a map, the [PgCatalog] representation. * @since 3.0 */ - var asPgMap: PgMap? = null + var asPgCatalog: PgCatalog? = null /** - * If this modifies a map, the feature cast to [NakshaMap]. + * If this modifies a map, the feature cast to [NakshaCatalog]. * @since 3.0 */ - var asNakshaMap: NakshaMap? = null + var asNakshaMap: NakshaCatalog? = null /** * If the feature is a collection, the [PgCollection] representation. diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriter.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriter.kt index d75111a4f6..1b333a334a 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriter.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriter.kt @@ -4,15 +4,9 @@ package naksha.psql import naksha.base.PlatformUtil import naksha.model.* -import naksha.model.objects.IndexList -import naksha.model.objects.IndexType -import naksha.model.objects.Member -import naksha.model.objects.MemberList -import naksha.model.objects.MemberType import naksha.model.objects.NakshaCollection -import naksha.model.objects.NakshaMap +import naksha.model.objects.NakshaCatalog import naksha.model.objects.NakshaTx -import naksha.model.objects.StandardMembers import naksha.model.request.* import kotlin.js.JsExport import kotlin.jvm.JvmField @@ -63,7 +57,7 @@ open class PgWriter internal constructor( * @since 3.0 */ val transaction: NakshaTx - get() = tx.transaction + get() = tx.nakshaTx /** * Performs the given writes. @@ -75,154 +69,73 @@ open class PgWriter internal constructor( return SuccessResponse().withTupleNumberList(tupleNumberList) } - /** - * Add the given write into an ordered array-list, order by `id` ascending. - * @since 3.0 - */ - private fun addSorted(arrayList: ArrayList, value: PgWrite) { - val index = arrayList.binarySearch { it.id.compareTo(value.id) } - val insertIndex = if (index < 0) -index - 1 else index - arrayList.add(insertIndex, value) - } - - /** - * Groups and orders writes by map, collection, partition, and eventually operation. - * - * @param map the map to write into. - * @param collection the collection to write into. - * @param writeOp the operation to perform, so `INSERT`, `UPSERT`, `UPDATE`, `DELETE` or `PURGE`. - * @param write the write instruction. - * @param byMap the map into which to store the write instruction. - * @param writeListCapacity the capacity of the write array, when a new one need to be allocated. - * @since 3.0 - */ - private fun addPgWrite( - map: PgMap, - collection: PgCollection, - writeOp: WriteOp, - write: PgWrite, - byMap: MutableMap>?>>>, - writeListCapacity: Int) { - var byCollection = byMap[map] - if (byCollection == null) { - byCollection = mutableMapOf() - byMap[map] = byCollection - } - var byPartition = byCollection[collection] - if (byPartition == null) { - byPartition = arrayOfNulls(if (collection.partitions > 1) collection.partitions + 1 else 1) - byCollection[collection] = byPartition - } - val partition = write.partition - // Note: byPartition reserved the first entry (index #0) for the case that there is no partitioning, - // therefore when there is no partitioning, the index becomes `0`, otherwise `1` to `n`. - var byWriteOp = byPartition[partition + 1] - if (byWriteOp == null) { - byWriteOp = mutableMapOf() - byPartition[partition + 1] = byWriteOp - } - var writeList = byWriteOp[writeOp] - if (writeList == null) { - writeList = ArrayList(writeListCapacity) - byWriteOp[writeOp] = writeList - } - addSorted(writeList, write) - } - - /** - * Group the writes by map, then by collection, and finally by partition with `-1` as partition number, when the table is not partitioned. - * @param writes the writes that should be done. - * @return a map by map, collection, and partition to write operations executed within. - * @since 3.0 - */ - private fun groupOperations(writes: ArrayList) - : MutableMap>?>>> { - val byMap = mutableMapOf>?>>>() - var writeListCapacity = writes.size - for (i in writes.indices) { - val write = writes[i] - val map = write.map - val collection = write.collection - val op = write.original.op - when (op) { - WriteOp.CREATE -> { - val f = write.feature ?: throw illegalArg("The feature #${write.i} is null") - // In a CREATE case, UNDEFINED means the same as `null` - val attachment = if (write.attachment === Write.UNDEFINED) null else write.attachment - val tuple = tx.created(write.map.head, write.collection.head, f, attachment) - write.tuple = tuple - val tupleNumber = tuple.tupleNumber - write.tupleNumber = tupleNumber - } - WriteOp.UPSERT -> { - // Note: We first try an INSERT, then, when that fails, we do an on-conflict UPDATE! - val f = write.feature ?: throw illegalArg("The feature #${write.i} is null") - val tuple = tx.created(write.map.head, write.collection.head, f, write.attachment) - write.tuple = tuple - val tupleNumber = tuple.tupleNumber - write.tupleNumber = tupleNumber - } - WriteOp.UPDATE -> { - val f = write.feature ?: throw illegalArg("The feature #${write.i} is null") - val tuple = tx.updated(write.map.head, write.collection.head, f, write.attachment, write.original.atomic) - write.tuple = tuple - val tupleNumber = tuple.tupleNumber - write.tupleNumber = tupleNumber - } - WriteOp.DELETE -> { - if (write.isTransactionModification) throw forbidden("Transactions must not be deleted or purged") - } - WriteOp.PURGE -> { - if (write.isTransactionModification) throw forbidden("Transactions must not be deleted or purged") - // Note: purge and delete are the same operation, except that a purge is not copied into deleted table! - } - else -> { - throw illegalArg("Unknown write operation: $op") - } - } - // In the first run, there is a chance that all writes go into same partition, after that, we can have one less! - addPgWrite(map, collection, op, write, byMap, writeListCapacity--) - } - // Sort all writes within the - return byMap - } - /** * Performs the given writes. * @param writes the writes to perform. * @return the tuple-numbers of the */ private fun executeWrites(writes: MutableList) : TupleNumberList { + if (writes.isEmpty()) return TupleNumberList() // Add the input-index. - val targetWrites = ArrayList(writes.size) - for (i in 0 ..< writes.size) targetWrites.add(PgWrite(writes[i], i)) + val pgWrites = ArrayList(writes.size) + for (i in 0 ..< writes.size) pgWrites.add(PgWrite(writes[i], i)) val savepointId = PlatformUtil.randomString() var conn: PgConnection? = null try { // This can be time-consuming, unless the connection is already open, try not to open it before we do this! - prepareWrite(targetWrites) - val byMap = groupOperations(targetWrites) + prepareWrite(pgWrites) + + // Order by: + // - catalog-number ASC + // - collection-number ASC + // - partition-number ASC + // - PURGE, DELETE, CREATE, UPSERT, UPDATE + // - feature-number ASC. + // + // The ordering is very important, because otherwise there can be deadlocks in the database at row-level locking! + pgWrites.sortWith { a, b -> + val catalogDiff = a.catalog.catalogNumber - b.catalog.catalogNumber + if (catalogDiff != 0) return@sortWith catalogDiff + val collectionDiff = a.collection.collectionNumber - b.collection.collectionNumber + if (collectionDiff != 0) return@sortWith collectionDiff + val partitionDiff = a.partitionNumber - b.partitionNumber + if (partitionDiff != 0) return@sortWith partitionDiff + val opDiff = a.op.order - b.op.order + if (opDiff != 0) return@sortWith opDiff + val featureNumberDiff = a.featureNumber - b.featureNumber + if (featureNumberDiff < 0) return@sortWith -1 + if (featureNumberDiff > 0) return@sortWith 1 + 0 + } // Perform the writes, if any error happens, we will roll back the session to where it was before we started. // Note: We must not close the connection, therefore no `session.useConnection().use {}`! conn = this.conn if (useSavepoint) conn.execute("SAVEPOINT \"$savepointId\"").close() - for (mapEntry in byMap) { - val map = mapEntry.key - val byCol = mapEntry.value - for (colEntry in byCol) { - val collection = colEntry.key - val byPartition = colEntry.value - for (i in byPartition.indices) { - val byWriteOp = byPartition[i] - if (byWriteOp != null) { - val partition = i - 1 // index #0 represents no partitioning - executeWrite(map, collection, partition, byWriteOp) - } - } + var start = 1 + var startTupleNumber: TupleNumber = pgWrites.first().tupleNumber ?: throw illegalState("PgWrite[0] without tuple-number") + val LAST = pgWrites.size - 1 + for (i in 1.. LAST) { + val pgWrite = pgWrites[i] + val tupleNumber = pgWrite.tupleNumber ?: throw illegalState("PgWrite[$i] without tuple-number") + if (start == i) { + startTupleNumber = tupleNumber + continue + } + if (i == LAST || + startTupleNumber.catalogNumber != tupleNumber.catalogNumber || + startTupleNumber.collectionNumber != tupleNumber.collectionNumber) + { + // Either `i` is a different catalog/collection, then write what we have. + // Or we are at the last write, then write as well what we have. + // Note: We need to split by collection, because every collection has its own columns! + executeWrite(pgWrites, start, i) + // Continue from where we ended, if this was LAST. + start = i + startTupleNumber = tupleNumber } } - // If everything worked out as expected, we can drop the savepoint. + // If everything worked out as expected, we can drop the savepoint, if there is any. if (useSavepoint) conn.execute("RELEASE SAVEPOINT \"$savepointId\"").close() } catch (t: Throwable) { if (conn != null && useSavepoint) conn.execute("ROLLBACK TO SAVEPOINT \"$savepointId\"").close() @@ -233,28 +146,28 @@ open class PgWriter internal constructor( val tupleNumbers = TupleNumberList() tupleNumbers.setCapacity(writes.size) val tupleList = ArrayList(writes.size) - val transaction = tx.transaction + val transaction = tx.nakshaTx var featuresModified = 0 - for (write in targetWrites) { + for (write in pgWrites) { val tupleNumber = write.tupleNumber tupleNumbers[write.i] = tupleNumber val tuple = write.tuple if (write.isFeatureModification) { - val map = write.map + val map = write.catalog val col = write.collection - val txCol = transaction.useMap(map.id, map.number).useCollection(col.id, col.number) + val txCol = transaction.useCatalog(map.id, map.catalogNumber).useCollection(col.id, col.collectionNumber) if (tupleNumber != null) { txCol.add(tupleNumber, col.partitions) } featuresModified += 1 - } else if (write.isMapModification) { - val map = write.asPgMap - if (map != null) transaction.useMap(map.id, map.number, write.action) + } else if (write.isCatalogModification) { + val map = write.asPgCatalog + if (map != null) transaction.useCatalog(map.id, map.catalogNumber, write.action) } else if (write.isCollectionModification) { - val map = write.map + val map = write.catalog val col = write.asPgCollection if (col != null) { - transaction.useMap(map.id, map.number).useCollection(col.id, col.number, write.action) + transaction.useCatalog(map.id, map.catalogNumber).useCollection(col.id, col.collectionNumber, write.action) map.invalidateCollection(col) } } @@ -270,272 +183,227 @@ open class PgWriter internal constructor( // Ensure that the needed physical schema and tables are created. // Ensure that the PgTupleWrite data class is ready for action. // After this has run, only the `tuple` is missing! - private fun prepareWrite(writes: ArrayList) { - for (write in writes) { - val featureId = write.original.id + private fun prepareWrite(pgWrites: ArrayList) { + for (pgWrite in pgWrites) { + val featureId = pgWrite.id + val op = pgWrite.op // Detect tbe map into which to write. - val mapId = write.original.mapId ?: throw illegalArg("The given write does not have a map-id") - val map = storage.adminMap.getPgMapById(conn, mapId) ?: - throw mapNotFound("The write #${write.i} refers to not existing map '$mapId'") - write.map = map + val catalogId = pgWrite.original.catalogId ?: throw illegalArg("The given write does not have a catalog-id") + val pgCatalog = storage.adminCatalog.getPgCatalogById(conn, catalogId) + ?: throw mapNotFound("The write #${pgWrite.i} refers to not existing map '$catalogId'") + pgWrite.catalog = pgCatalog // Detect the collection into which to write. - val colId = write.original.collectionId ?: throw illegalArg("The given write does not have a collection-id") - val collection = map.getPgCollectionById(conn, colId) ?: - throw collectionNotFound("The write #${write.i} refers to not existing collection '$colId'") - write.collection = collection + val collectionId = pgWrite.original.collectionId ?: throw illegalArg("The given write does not have a collection-id") + val pgCollection = pgCatalog.getPgCollectionById(conn, collectionId) + ?: throw collectionNotFound("The write #${pgWrite.i} refers to not existing collection '$collectionId'") + pgWrite.collection = pgCollection - // If this operation modifies a map. - if (write.isMapModification) { - val op = write.op - var pgMap = storage.adminMap.getPgMapById(null, write.id) ?: storage.adminMap.getPgMapById(conn, write.id) + // If this operation modifies a catalog. + if (pgWrite.isCatalogModification) { + var targetCatalog = storage.adminCatalog.getPgCatalogById(null, pgWrite.id) + ?: storage.adminCatalog.getPgCatalogById(conn, pgWrite.id) - val nakshaMap: NakshaMap? + val nakshaMap: NakshaCatalog? if (op == WriteOp.CREATE || op == WriteOp.UPSERT || op == WriteOp.UPDATE) { - val feature = write.feature ?: throw illegalArg("The write #${write.i} is $op, but the feature is null") - nakshaMap = if (feature is NakshaMap) feature else feature.proxy(NakshaMap::class) - nakshaMap.storageId = storage.id - if (pgMap == null) { + val feature = pgWrite.feature ?: throw illegalArg("The write #${pgWrite.i} is $op, but the feature is null") + nakshaMap = feature as? NakshaCatalog ?: feature.proxy(NakshaCatalog::class) + nakshaMap.databaseId = storage.id + if (targetCatalog == null) { if (op == WriteOp.UPDATE) { - throw mapNotFound("The UPDATE (write #${write.i}) failed, because the map '$featureId' does not exist") + throw mapNotFound("The UPDATE (write #${pgWrite.i}) failed, because the map '$featureId' does not exist") } - pgMap = PgMap(storage, nakshaMap) - createPgMap(pgMap) + targetCatalog = PgCatalog(storage, nakshaMap) + createPgCatalog(targetCatalog) } else if (op == WriteOp.CREATE) { - throw mapExists("The write #${write.i} failed, because the map '$featureId' does exist already") + throw mapExists("The write #${pgWrite.i} failed, because the map '$featureId' does exist already") } } else if (op == WriteOp.DELETE || op == WriteOp.PURGE) { - if (pgMap != null) { - deletePgMap(pgMap) + if (targetCatalog != null) { + deletePgMap(targetCatalog) } nakshaMap = null } else { - throw illegalState("The write #${write.i} refers to an unsupported operation: '$op'") + throw illegalState("The write #${pgWrite.i} refers to an unsupported operation: '$op'") } - write.asPgMap = pgMap - write.asNakshaMap = nakshaMap + pgWrite.asPgCatalog = targetCatalog + pgWrite.asNakshaMap = nakshaMap } // If this operation modifies a collection. - if (write.isCollectionModification) { - val op = write.op - var pgCollection = map.getPgCollectionById(null, write.id) ?: map.getPgCollectionById(conn, write.id) + if (pgWrite.isCollectionModification) { + var targetCollection = pgCatalog.getPgCollectionById(null, pgWrite.id) ?: pgCatalog.getPgCollectionById(conn, pgWrite.id) val nakshaCollection: NakshaCollection? if (op == WriteOp.CREATE || op == WriteOp.UPSERT || op == WriteOp.UPDATE) { - val feature = write.feature ?: throw illegalArg("The write #${write.i} is $op, but the feature is null") - nakshaCollection = if (feature is NakshaCollection) feature else feature.proxy(NakshaCollection::class) - if (pgCollection == null) { + val feature = pgWrite.feature ?: throw illegalArg("The write #${pgWrite.i} is $op, but the feature is null") + nakshaCollection = feature as? NakshaCollection ?: feature.proxy(NakshaCollection::class) + if (targetCollection == null) { if (op == WriteOp.UPDATE) { throw collectionNotFound( - "The UPDATE (write #${write.i}) failed, because the collection '$featureId' does not exist in map '$mapId'" + "The UPDATE (write #${pgWrite.i}) failed, because the collection '$featureId' does not exist in map '$catalogId'" ) } - // Normalize members and indices (inject defaults, validate, sort) — CREATE only. - normalizeCollection(nakshaCollection) - pgCollection = PgCollection(map, nakshaCollection) - createPgCollection(pgCollection) + targetCollection = PgCollection(pgCatalog, nakshaCollection) + createPgCollection(targetCollection) } else if (op == WriteOp.CREATE) { throw collectionExists( - "The write #${write.i} failed, because the collection '$featureId' does exist already in map '$mapId'" + "The write #${pgWrite.i} failed, because the collection '$featureId' does exist already in map '$catalogId'" ) } else { - // UPSERT or UPDATE on an existing collection: diff schema (members + custom indexes) and apply. - pgCollection.applyMembersAndIndexes(conn, pgCollection.head, nakshaCollection, write.original.force) + // UPSERT or UPDATE on an existing collection: Ensure that no invalid changes are asked for. + targetCollection.verifyNewHeadState(nakshaCollection) } } else if (op == WriteOp.DELETE || op == WriteOp.PURGE) { - if (pgCollection != null) { - deletePgCollection(pgCollection) + if (targetCollection != null) { + deletePgCollection(targetCollection) } nakshaCollection = null } else { - throw illegalState("The write #${write.i} refers to an unsupported operation: '$op'") + throw illegalState("The write #${pgWrite.i} refers to an unsupported operation: '$op'") } - write.asPgCollection = pgCollection - write.asNakshaCollection = nakshaCollection + pgWrite.asPgCollection = targetCollection + pgWrite.asNakshaCollection = nakshaCollection } - } - } - private fun MutableMap>.getOrCreate(collection: PgCollection): MutableList { - var list = this[collection] - if (list == null) { - list = ArrayList() - this[collection] = list + when (op) { + WriteOp.CREATE -> { + val f = pgWrite.feature ?: throw illegalArg("The feature #${pgWrite.i} is null") + // In a CREATE case, UNDEFINED means the same as `null` + val tuple = Tuple.encodeFeature(f, pgCollection.head, Action.CREATE, session, null) + pgWrite.tuple = tuple + pgWrite.tupleNumber = tuple.tupleNumber + } + WriteOp.UPSERT -> { + // Note: We first try an INSERT, then, when that fails, we do an on-conflict UPDATE! + val f = pgWrite.feature ?: throw illegalArg("The feature #${pgWrite.i} is null") + val tuple = Tuple.encodeFeature(f, pgCollection.head, Action.CREATE, session, null) + pgWrite.tuple = tuple + pgWrite.tupleNumber = tuple.tupleNumber + } + WriteOp.UPDATE -> { + val f = pgWrite.feature ?: throw illegalArg("The feature #${pgWrite.i} is null") + val tuple = Tuple.encodeFeature(f, pgCollection.head, Action.UPDATE, session, null) + pgWrite.tuple = tuple + pgWrite.tupleNumber = tuple.tupleNumber + } + WriteOp.DELETE, WriteOp.PURGE -> { + // TODO: For DELETE we want to support new states, so providing a feature! + if (pgWrite.isTransactionModification) throw forbidden("Transactions must not be deleted or purged") + } + else -> { + throw illegalArg("Unknown write operation: $op") + } + } } - return list } - private fun executeWrite(map: PgMap, collection: PgCollection, partition: Int, byWriteOp: Map>) { - // DELETE - val deletes = byWriteOp[WriteOp.DELETE] - if (deletes != null) { - val tupleWriter = PgWriterDelete(this, collection, partition, deletes) + /** + * Execute all writes between `start` _(inclusive)_ and `end` _(exclusive)_. + * @param pgWrites the list of ordered writes. + * @param start the index in the list of the first element to execute. + * @param end the index in the list of the first element **NOT** to execute _(excluded)_. + */ + private fun executeWrite(pgWrites: ArrayList, start: Int, end: Int) { + if (start == end) return + // We expect that all writes go into the same catalog and collection. + val first = pgWrites[start] + val pgCatalog = first.catalog + val pgCollection = first.collection + var s = start + var e = start + + // -------------------------------- DELETE ------------------------------------------------------------------------ + while (e < end) { + val pgWrite = pgWrites[e] + check(pgWrite.catalog.catalogNumber != pgCatalog.catalogNumber) + check(pgWrite.collection.collectionNumber != pgCollection.collectionNumber) + if (pgWrite.op != WriteOp.DELETE) break + e++ + } + if (e > s) { + val tupleWriter = PgWriterDelete(this, pgCollection, pgWrites, s, e, purge = false) tupleWriter.execute(conn) + s = e } - // PURGE - val purges = byWriteOp[WriteOp.PURGE] - if (purges != null) { - val tupleWriter = PgWriterDelete(this, collection, partition, purges, purge = true) + // -------------------------------- PURGE ------------------------------------------------------------------------ + while (e < end) { + val pgWrite = pgWrites[e] + check(pgWrite.catalog.catalogNumber != pgCatalog.catalogNumber) + check(pgWrite.collection.collectionNumber != pgCollection.collectionNumber) + if (pgWrite.op != WriteOp.PURGE) break + e++ + } + // + if (e > s) { + val tupleWriter = PgWriterDelete(this, pgCollection, pgWrites, s, e, purge = true) tupleWriter.execute(conn) + e = s } - // INSERT - val inserts = byWriteOp[WriteOp.CREATE] - if (inserts != null) { - val tupleWriter = PgWriterInsert(this, collection, partition, inserts) + // -------------------------------- CREATE ------------------------------------------------------------------------ + while (e < end) { + val pgWrite = pgWrites[e] + check(pgWrite.catalog.catalogNumber != pgCatalog.catalogNumber) + check(pgWrite.collection.collectionNumber != pgCollection.collectionNumber) + if (pgWrite.op != WriteOp.CREATE) break + e++ + } + if (e > s) { + val tupleWriter = PgWriterInsert(this, pgCollection, pgWrites, s, e) tupleWriter.execute(conn) + e = s } - // UPSERT - val upserts = byWriteOp[WriteOp.UPSERT] - if (upserts != null) { - val tupleWriter = PgWriterUpsert(this, collection, partition, upserts) + // -------------------------------- UPSERT ------------------------------------------------------------------------ + while (e < end) { + val pgWrite = pgWrites[e] + check(pgWrite.catalog.catalogNumber != pgCatalog.catalogNumber) + check(pgWrite.collection.collectionNumber != pgCollection.collectionNumber) + if (pgWrite.op != WriteOp.UPSERT) break + e++ + } + if (e > s) { + val tupleWriter = PgWriterUpsert(this, pgCollection, pgWrites, s, e) tupleWriter.execute(conn) + e = s } - // UPDATE - val updates = byWriteOp[WriteOp.UPDATE] - if (updates != null) { - val tupleWriter = PgWriterUpdate(this, collection, partition, updates) + // -------------------------------- UPDATE ------------------------------------------------------------------------ + while (e < end) { + val pgWrite = pgWrites[e] + check(pgWrite.catalog.catalogNumber != pgCatalog.catalogNumber) + check(pgWrite.collection.collectionNumber != pgCollection.collectionNumber) + if (pgWrite.op != WriteOp.UPDATE) break + e++ + } + if (e > s) { + val tupleWriter = PgWriterUpdate(this, pgCollection, pgWrites, s, e) tupleWriter.execute(conn) + e = s } + + if (e != s) throw illegalState("We missed some writes beyond $s in the ordered write-operation list") } /** - * Invoked when a [NakshaMap][naksha.model.objects.NakshaMap] should be physically created. - * @param map the map that should be physically created. + * Invoked when a [NakshaMap][naksha.model.objects.NakshaCatalog] should be physically created. + * @param catalog the catalog that should be physically created. * @since 3.0 */ - protected open fun createPgMap(map: PgMap) { - storage.adminMap.createPgMap(conn, map) + protected open fun createPgCatalog(catalog: PgCatalog) { + storage.adminCatalog.createPgCatalog(conn, catalog) } /** - * Invoked when a [NakshaMap][naksha.model.objects.NakshaMap] was created. + * Invoked when a [NakshaMap][naksha.model.objects.NakshaCatalog] was created. * @param map the map that was just created. * @since 3.0 */ - protected open fun deletePgMap(map: PgMap) { - storage.adminMap.deletePgMap(conn, map) - } - - /** - * Normalizes a [NakshaCollection] before it is physically created: - * - * **Members** — when the client provides an explicit (non-null) `members` list: - * - Mandatory members (`fn`, `version`, `id`, `feature`) are silently deduplicated if declared - * with the exact same type, or rejected with [NakshaError.ILLEGAL_ARGUMENT] on type mismatch. - * - Non-mandatory names that conflict with any reserved [PgColumn] name are rejected. - * - The surviving client-declared members are sorted for optimal PostgreSQL column layout. - * When `members` is **null** (backward-compatible) it is left as-is; the DDL layer will use the - * full built-in column schema. - * - * **Indices** — when the client provides an explicit (non-null) `indices` list: - * - Any entry whose [naksha.model.objects.Index.internal] flag is `true` is silently dropped - * (clients must not declare storage-managed indices). - * When `indices` is **null** (backward-compatible) it is left as-is; the DDL layer will create - * all default optional indices. - * - * This method modifies [collection] in place and returns it. - */ - protected fun normalizeCollection(collection: NakshaCollection): NakshaCollection { - // ── Members ───────────────────────────────────────────────────────────────── - val clientMembers = collection.members - if (clientMembers != null) { - val mandatoryByName = PgColumn.mandatoryMembers.associateBy { it.name } - val normalizedMembers = MemberList() - for (m in clientMembers) { - if (m == null) continue - val mandatory = mandatoryByName[m.name] - if (mandatory != null) { - // Mandatory column declared by client: exact type → silently drop; type conflict → reject. - if (m.dataType != mandatory.dataType) { - throw illegalArg( - "Member '${m.name}' is a mandatory column with type ${mandatory.dataType}; " + - "client declared it with type ${m.dataType}" - ) - } - } else { - normalizedMembers.add(m) - } - } - PgCustomMemberValues.validateMemberNames(normalizedMembers) - PgCustomMemberValues.sortMembersForStorage(normalizedMembers) - collection.members = normalizedMembers - } - - // ── Indices ────────────────────────────────────────────────────────────────── - val clientIndices = collection.indices - if (clientIndices != null) { - val normalizedIndices = IndexList() - for (idx in clientIndices) { - if (idx == null) continue - if (idx.internal) continue // clients must not declare internal indices - normalizedIndices.add(idx) - } - // When members are explicitly set, validate that every index column name - // refers to a known member (standard built-in or custom declared). - if (clientMembers != null) { - val knownNames = buildSet { - addAll(StandardMembers.ALL_NAMES) - for (m in collection.members ?: emptyList()) if (m != null) add(m.name) - } - // Build a lookup: member name → dataType (standard + custom) - val memberTypeByName: Map = buildMap { - for (sm in StandardMembers.ALL) put(sm.name, sm.dataType) - for (m in collection.members ?: emptyList()) if (m != null) put(m.name, m.dataType) - } - for (idx in normalizedIndices) { - if (idx == null) continue - val firstColName = idx.on.firstOrNull { it != null } - for (colName in idx.on) { - if (colName != null && colName !in knownNames) { - throw illegalArg( - "Index '${idx.name}' references unknown member '$colName'. " + - "Declare the member in the collection's members list, or use a standard member name." - ) - } - } - // Type-compatibility: SPATIAL index requires a SPATIAL member as its first column; - // BTREE/other index must not target a SPATIAL member (no ordering defined for TWKB). - if (firstColName != null) { - val firstColType = memberTypeByName[firstColName] - when (idx.type) { - IndexType.SPATIAL -> if (firstColType != MemberType.SPATIAL) { - throw illegalArg( - "SPATIAL index '${idx.name}' must target a member of type SPATIAL, " + - "but '$firstColName' has type $firstColType." - ) - } - IndexType.TAGS -> if (firstColType != MemberType.TAGS && firstColType != MemberType.TAGS_FROM_ARRAY) { - throw illegalArg( - "TAGS index '${idx.name}' must target a member of type TAGS or TAGS_FROM_ARRAY, " + - "but '$firstColName' has type $firstColType." - ) - } - IndexType.SET -> if (firstColType != MemberType.SET) { - throw illegalArg( - "SET index '${idx.name}' must target a member of type SET, " + - "but '$firstColName' has type $firstColType." - ) - } - else -> if (firstColType == MemberType.SPATIAL) { - throw illegalArg( - "Index '${idx.name}' of type ${idx.type} cannot target SPATIAL member '$firstColName'. " + - "Use IndexType.SPATIAL for geometry columns." - ) - } - } - } - } - } - collection.indices = normalizedIndices - } - - return collection + protected open fun deletePgMap(map: PgCatalog) { + storage.adminCatalog.deletePgCatalog(conn, map) } /** @@ -544,7 +412,7 @@ open class PgWriter internal constructor( * @since 3.0 */ protected open fun createPgCollection(collection: PgCollection) { - collection.map.createPgCollection(conn, collection) + collection.catalog.createPgCollection(conn, collection) } /** @@ -553,6 +421,6 @@ open class PgWriter internal constructor( * @since 3.0 */ protected open fun deletePgCollection(collection: PgCollection) { - collection.map.deletePgCollection(conn, collection) + collection.catalog.deletePgCollection(conn, collection) } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterBase.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterBase.kt index c4757af72d..4ec1f825ae 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterBase.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterBase.kt @@ -2,9 +2,19 @@ package naksha.psql import naksha.base.Int64 import naksha.base.IntMutable +import naksha.base.fn.Fn1 +import naksha.base.fn.Fx1 +import naksha.base.fn.Fx2 +import naksha.base.fn.Fx3 +import naksha.model.Tuple +import naksha.model.TupleNumber import naksha.model.Version +import naksha.model.illegalArg import naksha.model.illegalState import naksha.model.objects.NakshaTx +import naksha.model.objects.StandardMembers +import kotlin.collections.mutableMapOf +import kotlin.jvm.JvmStatic /** * Base class for all operations, so for: @@ -20,37 +30,59 @@ internal abstract class PgWriterBase protected constructor( * The [writer][PgWriter] to which this write is bound. * @since 3.0 */ - val writer: PgWriter, + val pgWriter: PgWriter, /** * The collection to operate upon. * @since 3.0 */ - val collection: PgCollection, + val pgCollection: PgCollection, /** - * The partition to write into, `-1` if writes should enter base table. + * The list of writes to perform. * @since 3.0 */ - val partition: Int, + val pgWrites: List, /** - * The list of writes to perform. - * @since 3.0 + * The index of first [PgWrite] from the [pgWrites] list to process. + */ + val start: Int, + + /** + * The index of first [PgWrite] from the [pgWrites] list to **NOT** process. */ - val writes: List, + val end: Int, ) { + companion object PgWriterBase_C { + @JvmStatic + protected val UNDEFINED: ByteArray = "undefined".encodeToByteArray() + } + val session: PgSession - get() = writer.session + get() = pgWriter.session val storageNumber: Int64 - get() = collection.storage.number + get() = pgCollection.storage.number - val mapNumber: Int - get() = collection.map.number + val catalogNumber: Int + get() = pgCollection.catalog.catalogNumber val collectionNumber: Int - get() = collection.number + get() = pgCollection.collectionNumber + + /** The _HEAD_ table. */ + val headTable = pgCollection.headTable + /** The quoted name of the _HEAD_ table. */ + val headIdent = headTable.quotedName + /** The _HISTORY_ table or `null`, if _HISTORY_ is disabled. */ + val historyTable = if (pgCollection.storeHistory) pgCollection.historyTable else null + /** The quoted name of the _HISTORY_ table or `null`, if _HISTORY_ is disabled. */ + val historyIdent = historyTable?.quotedName + /** The `id` column */ + val ID: PgColumn = pgCollection.column(StandardMembers.Id) ?: throw illegalState("The collection does not have an 'id' column.") + /** The change-count column, if there is any defined. */ + val CC: PgColumn? = pgCollection.column(StandardMembers.ChangeCount) /** * The transaction to operate upon. @@ -66,18 +98,16 @@ internal abstract class PgWriterBase protected constructor( * @since 3.0 */ val transaction: NakshaTx - get() = tx.transaction + get() = tx.nakshaTx /** * The rows to write. * @since 3.0 */ - val inRows = PgColumnRows() - .withStorageNumber(storageNumber) - .withMapNumber(mapNumber) + val inRows = PgRows() + .withDatabaseNumber(storageNumber) + .withCatalogNumber(catalogNumber) .withCollectionNumber(collectionNumber) - .withDefaultDataEncoding(collection.head.dataEncoding ?: naksha.model.Naksha.DEFAULT_DATA_ENCODING) - .withMinSize(writes.size) /** * Generates a live mapping between the write instructions and the partition-index into which they will write. @@ -88,10 +118,10 @@ internal abstract class PgWriterBase protected constructor( */ val featureCountByPartition: Map get() { - val partitions = collection.head.partitions + val partitions = pgCollection.partitions val partIndices = mutableMapOf() - for (i in writes.indices) { - val write = writes[i] + for (i in 0 ..< pgWrites.size) { + val write = pgWrites[i] val partIndex = write.tupleNumber?.partitionIndex(partitions) ?: -1 val existing = partIndices[partIndex] if (existing != null) existing.plus(1) else partIndices[partIndex] = IntMutable(1) @@ -113,61 +143,39 @@ internal abstract class PgWriterBase protected constructor( */ val featureCountByPartitionJoined: String get() { - val partitions = collection.head.partitions - return if (partitions <= 1) "-1: ${writes.size}" + val partitions = pgCollection.head.partitions + return if (partitions <= 1) "-1: ${pgWrites.size}" else featureCountByPartition.entries.joinToString(", ") { "${it.key}=${it.value.value}" } } - /** - * If this write should be done into a partition. - */ - val writeIntoPartition: Boolean = partition >= 0 - - /** - * The year when the transaction started, for transactions and history writes. - */ - val year: Int = tx.version.year - - private fun initHeadTable(): PgTable { - if (writeIntoPartition) { - return collection.headTable.partitions[partition] - } - return collection.headTable - } - - /** - * The head table to write into. - */ - val headTable: PgTable = initHeadTable() - - private fun initHistoryTable(): PgTable? { - val hst = collection.historyTable ?: return null - var yearTable: PgHistoryYear? = hst.years[year] - if (yearTable == null) { - hst.addYear(year) - yearTable = hst.years[year] - if (yearTable == null) { - throw illegalState("Internal error, failed to add history year $year") - } - } - return if (writeIntoPartition) yearTable.partitions[partition] else yearTable - } - - /** - * The history table to write into, if any. - */ - val historyTable: PgTable? = initHistoryTable() - /** * Execute the operation. * @param conn the connection to be used. * @since 3.0 */ fun execute(conn: PgConnection) { - collection.map.setSearchPath(conn) + pgCollection.catalog.setSearchPath(conn) return doExecute(conn) } + /** + * Add all tuple from [pgWrites], expects that the columns are prepared. + * @param lambda a lambda optionally being called after every imported tuple, with `row`, `tuple` and `pgWrite` as arguments. + * @return the number of rows loaded. + */ + protected fun loadAllTuple(lambda: Fx3? = null): Int { + var row = 0 + inRows.setMinRows(inRows.size + (end - start)) + for (i in start ..< end) { + val pgWrite = pgWrites[i] + val tuple = pgWrite.tuple ?: throw illegalArg("The write #$i has no tuple, failed to load all tuple") + inRows[row] = tuple + lambda?.call(row, tuple, pgWrite) + row++ + } + return row + } + /** * Execute the operation. * @param conn the connection to be used. diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterDelete.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterDelete.kt index ee7603759f..08633a3af4 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterDelete.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterDelete.kt @@ -5,6 +5,11 @@ import naksha.base.Platform.PlatformCompanion.logger import naksha.base.PlatformUtil import naksha.model.* import naksha.model.objects.StoreMode +import naksha.model.objects.MemberType +import naksha.model.objects.StandardMembers +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.NEXT_VERSION +import naksha.psql.PgColumn.PgColumn_C.VERSION /** * Execute a [DELETE][naksha.model.request.WriteOp.DELETE] or PURGE. @@ -24,118 +29,117 @@ import naksha.model.objects.StoreMode * @since 3.0 * @see [PgWriter] */ -internal class PgWriterDelete(writer: PgWriter, collection: PgCollection, partition: Int, writes: List, val purge: Boolean = false) - : PgWriterBase(writer, collection, partition, writes) -{ +internal class PgWriterDelete( + pgWriter: PgWriter, + pgCollection: PgCollection, + pgWrites: List, + start: Int, + end: Int, + val purge: Boolean = false +) : PgWriterBase(pgWriter, pgCollection, pgWrites, start, end) { init { - inRows.addColumn("id", PgType.STRING) - inRows.addColumn("expected_version", PgType.INT64) - for (e in writes.withIndex()) { - val row = e.index - val write = e.value - inRows.set(row, "id", write.id) - inRows.set(row, "expected_version", write.version?.txn) + inRows.addColumn(FN.ident, MemberType.INT64) + inRows.addColumn("expected_version", MemberType.INT64) + var row = 0 + for (i in start until end) { + val pgWrite = pgWrites[i] + inRows.set(row, FN.ident, pgWrite.tupleNumber!!.featureNumber) + inRows.set(row, "expected_version", pgWrite.version?.number) + row++ } + check(row == (end-start)) } - private fun plan(conn: PgConnection, collection: PgCollection): PgWriterPlan { - // We do not insert into history, if the table does not exist, or is disabled - val insert_into_history = if (historyTable != null && collection.head.storeHistory == StoreMode.ON) historyTable else null - + private fun plan(conn: PgConnection): PgWriterPlan { // The new version with action bits set to DELETED (2). - val deleted_version = "(${tx.version.txn}::int8 | 2)" + val deleted_version = "(${tx.version.number}::int8 | 2)" // All input provided by client, `id` and optionally `expected_version` val query = """WITH query AS ( - SELECT * FROM UNNEST($1, $2) AS t(id, expected_version) + SELECT * FROM UNNEST($1, $2) AS t($FN, expected_version) )""" // Select id and version of all rows matching query.id — used for conflict detection. val head_select = """, head_select AS ( - SELECT head.id AS id, head.fn AS fn, head.version AS version - FROM ${headTable.quotedName} AS head, query - WHERE head.id = query.id + SELECT head.$FN AS $FN, head.$VERSION AS $VERSION + FROM $headIdent AS head, query + WHERE head.$FN = query.$FN )""" - val effHead = collection.effectiveHeadColumns - val effCopyHistory = collection.effectiveCopyIntoHistoryColumns - val hasCc = PgColumn.cc in effHead - // Select the HEAD rows to act on: // - Optional atomic version check (expected_version). // - Skip rows already deleted ((version & 3) >= 2) — idempotent. val head_row = """, head_row AS ( - SELECT ${effHead.joinToString(", ") { "head.${it.name} AS ${it.name}" }} - FROM ${headTable.quotedName} AS head, query - WHERE head.id = query.id - AND (query.expected_version IS NULL OR (query.expected_version & -4) = (head.version & -4)) - AND (head.version & 3) < 2 + SELECT ${pgCollection.joinColumns { column -> "head.$column AS $column" }} + FROM $headIdent AS head, query + WHERE head.$FN = query.$FN + AND (query.expected_version IS NULL OR (query.expected_version & -4) = (head.$VERSION & -4)) + AND (head.$VERSION & 3) < 2 )""" // Archive the current HEAD row into history (identical to how UPDATE does it). - // next_version = the new deleted version, signalling "succeeded by a deletion". - val head_to_history = if (insert_into_history != null) """, head_to_history AS ( - INSERT INTO ${insert_into_history.quotedName} (${PgColumn.next_version}, ${effCopyHistory.joinToString(",") { it.name }}) - SELECT $deleted_version AS ${PgColumn.next_version}, - ${effCopyHistory.joinToString(", ") { "head_row.${it.name} AS ${it.name}" }} + // next_version = the new deleted version, signaling "succeeded by a deletion". + val head_to_history = if (historyTable != null) """, head_to_history AS ( + INSERT INTO $historyIdent ($NEXT_VERSION, ${pgCollection.joinColumns { if (it.name != NEXT_VERSION.name) it.ident else null }}) + SELECT $deleted_version AS $NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else "head_row.$column AS $column" }} FROM head_row - RETURNING id, fn, version + RETURNING $FN, $VERSION )""" else "" // For DELETE: UPDATE version (action bits = DELETED) and cc in HEAD. // Only these two control-columns change; all data columns remain identical. val head_updated = if (!purge) """, head_updated AS ( - UPDATE ${headTable.quotedName} - SET - ${PgColumn.version.name} = $deleted_version${if (hasCc) ",\n ${PgColumn.cc.name} = ${headTable.quotedName}.${PgColumn.cc.name} + 1" else ""} + UPDATE $headIdent + SET $VERSION = $deleted_version${if (CC!=null) ", $CC = $headIdent.$CC + 1" else ""} FROM head_row - WHERE ${headTable.quotedName}.fn = head_row.fn - RETURNING ${headTable.quotedName}.id, ${headTable.quotedName}.fn, ${headTable.quotedName}.version${if (hasCc) ",\n ${headTable.quotedName}.${PgColumn.cc.name}" else ""} + WHERE $headIdent.$FN = head_row.$FN + RETURNING $headIdent.$FN, $headIdent.$VERSION${if (CC!=null) ", $headIdent.$CC" else ""} )""" else "" // For PURGE: DELETE the HEAD row entirely. val head_deleted = if (purge) """, head_deleted AS ( - DELETE FROM ${headTable.quotedName} - WHERE (fn, version) IN (SELECT fn, version FROM head_row) - RETURNING id, fn, version + DELETE FROM $headIdent + WHERE ($FN, $VERSION) IN (SELECT $FN, $VERSION FROM head_row) + RETURNING $FN, $VERSION )""" else "" // For PURGE only: also write a tombstone record into history to explicitly mark // end-of-lifetime. The tombstone's next_version == version (closed interval). - val history_tombstone = if (purge && insert_into_history != null) """, history_tombstone AS ( - INSERT INTO ${insert_into_history.quotedName} - (${if (hasCc) "${PgColumn.cc}, " else ""}${PgColumn.fn}, ${PgColumn.version}, ${PgColumn.next_version}, ${PgColumn.base_tn}, ${PgColumn.tombstoneColumns.joinToString(", ")}) - SELECT ${if (hasCc) "head_row.cc, " else ""}head_row.fn, $deleted_version, $deleted_version, null::bytea, - ${PgColumn.tombstoneColumns.joinToString(", ") { "head_row.${it.name} AS ${it.name}" }} + val history_tombstone = if (purge && historyTable != null) """, history_tombstone AS ( + INSERT INTO ${historyTable.quotedName} + ($VERSION, $NEXT_VERSION, + ${pgCollection.joinColumns { if (VERSION eq it || NEXT_VERSION eq it) null else it.ident }}) + SELECT $deleted_version AS $VERSION, $deleted_version AS $NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (VERSION eq column || NEXT_VERSION eq column) null else "head_row.$column AS $column" }} FROM head_row - RETURNING id, fn, version + RETURNING $FN, $VERSION )""" else "" // The returned row for DELETE is the updated HEAD row (same data, new version/cc). // We reconstruct it from head_row overriding the two changed columns. - val effHeadNoCcVersionFn = effHead.filter { it !== PgColumn.cc && it !== PgColumn.version && it !== PgColumn.fn } val SQL = """$query$head_select$head_row$head_to_history$head_updated$head_deleted$history_tombstone SELECT - head_row.fn AS fn, - $deleted_version AS version, - ${if (hasCc) "COALESCE(head_updated.${PgColumn.cc.name}, head_row.${PgColumn.cc.name} + 1) AS ${PgColumn.cc}," else ""} - ${effHeadNoCcVersionFn.joinToString(", ") { "head_row.${it.name} AS ${it.name}" }}${if (effHeadNoCcVersionFn.isNotEmpty()) "," else ""} - null::int8 AS ${PgColumn.next_version}, - ${if (head_to_history.isNotEmpty()) "head_to_history.version AS head_history_version," else ""} - ${if (history_tombstone.isNotEmpty()) "history_tombstone.version AS history_version," else ""} - head_select.fn AS select_fn, - head_select.version AS select_version, - head_row.version AS head_version, - ${if (!purge) "head_updated.version AS deleted_version," else "head_deleted.version AS deleted_version,"} - query.id AS query_id, + head_row.$FN AS $FN, + $deleted_version AS $VERSION, + null::int8 AS $NEXT_VERSION, + ${if (CC!=null) "COALESCE(head_updated.$CC, head_row.$CC + 1) AS $CC," else ""} + ${pgCollection.joinColumns { col -> if (col eq CC || col eq FN || col eq VERSION || col eq NEXT_VERSION) null else "head_row.$col AS $col" }}, + ${if (head_to_history.isNotEmpty()) "head_to_history.$VERSION AS head_history_version," else "null AS head_history_version"} + ${if (history_tombstone.isNotEmpty()) "history_tombstone.version AS history_version," else "null AS history_version"} + head_select.$FN AS select_fn, + head_select.$VERSION AS select_version, + head_row.$VERSION AS head_version, + ${if (!purge) "head_updated.$VERSION AS deleted_version," else "head_deleted.$VERSION AS deleted_version,"} + query.$FN AS query_fn, query.expected_version AS query_expected_version FROM query -LEFT JOIN head_row ON head_row.id = query.id -${if (!purge) "LEFT JOIN head_updated ON head_updated.id = query.id" else ""} -${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_history.id = query.id" else ""} -${if (history_tombstone.isNotEmpty()) "LEFT JOIN history_tombstone ON history_tombstone.id = query.id" else ""} -LEFT JOIN head_select ON head_select.id = query.id -${if (purge) "LEFT JOIN head_deleted ON head_deleted.id = query.id" else ""} +LEFT JOIN head_row ON head_row.$FN = query.$FN +${if (!purge) "LEFT JOIN head_updated ON head_updated.$FN = query.$FN" else ""} +${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_history.$FN = query.$FN" else ""} +${if (history_tombstone.isNotEmpty()) "LEFT JOIN history_tombstone ON history_tombstone.$FN = query.$FN" else ""} +LEFT JOIN head_select ON head_select.$FN = query.$ID +${if (purge) "LEFT JOIN head_deleted ON head_deleted.$FN = query.$FN" else ""} ;""" val typeNames = inRows.typeNames() val pgPlan = conn.prepare(SQL, typeNames) @@ -143,22 +147,28 @@ ${if (purge) "LEFT JOIN head_deleted ON head_deleted.id = query.id" else ""} } override fun doExecute(conn: PgConnection) { - if (writes.isEmpty()) return - val outRows = PgColumnRows() - .withStorageNumber(storageNumber) - .withMapNumber(mapNumber) + if (pgWrites.isEmpty()) return + val outRows = PgRows() + .withDatabaseNumber(storageNumber) + .withCatalogNumber(catalogNumber) .withCollectionNumber(collectionNumber) - .withDefaultDataEncoding(collection.head.dataEncoding ?: Naksha.DEFAULT_DATA_ENCODING) - .addColumns(collection.effectiveHistoryColumns) - .addColumn("head_history_version", PgType.INT64) - .addColumn("history_version", PgType.INT64) - .addColumn("select_fn", PgType.INT64) - .addColumn("select_version", PgType.INT64) - .addColumn("head_version", PgType.INT64) - .addColumn("deleted_version", PgType.INT64) - .addColumn("query_id", PgType.STRING) - .addColumn("query_expected_version", PgType.INT64) - val plan = plan(conn, collection) + outRows.addColumn(FN) + outRows.addColumn(VERSION) + outRows.addColumn(NEXT_VERSION) + if (CC!=null) outRows.addColumn(CC) + for (col in pgCollection.columns) { + if (col eq CC || col eq FN || col eq VERSION || col eq NEXT_VERSION) continue + outRows.addColumn(col) + } + outRows.addColumn("head_history_version", MemberType.INT64) + .addColumn("history_version", MemberType.INT64) + .addColumn("select_fn", MemberType.INT64) + .addColumn("select_version", MemberType.INT64) + .addColumn("head_version", MemberType.INT64) + .addColumn("deleted_version", MemberType.INT64) + .addColumn("query_fn", MemberType.INT64) + .addColumn("query_expected_version", MemberType.INT64) + val plan = plan(conn) val array = inRows.values() if (PlatformUtil.ENABLE_INFO) { if (session.logQueries) { @@ -173,24 +183,24 @@ ${if (purge) "LEFT JOIN head_deleted ON head_deleted.id = query.id" else ""} val cursor = plan.pgPlan.execute(array) val end = Platform.currentNanos() val seconds = (end.toDouble() - start.toDouble()) / 1e9 - if (writes.size != 1 || writes[0].isFeatureModification) { + if (pgWrites.size != 1 || pgWrites[0].isFeatureModification) { logger.info( - "${if (purge) "PURGE" else "DELETE"} for ${writes.size} rows resulted in ${inRows.size} rows deleted, ${seconds * 1000}ms, ${inRows.size / seconds} features/s, partitions: $featureCountByPartitionJoined" + "${if (purge) "PURGE" else "DELETE"} for ${pgWrites.size} rows resulted in ${inRows.size} rows deleted, ${seconds * 1000}ms, ${inRows.size / seconds} features/s, partitions: $featureCountByPartitionJoined" ) } cursor.fetch().use { cursor -> - outRows.addAll(cursor) + outRows.readAll(cursor) for (row in 0 until outRows.size) { - val write = writes[row] - val id = outRows.getString(row, "query_id") ?: throw generalException("Missing 'query_id' in result") + val write = pgWrites[row] + val fn = outRows.getString(row, "query_fn") ?: throw generalException("Missing 'query_fn' in result") val tuple = outRows[row] if (tuple != null) write.tuple = tuple - val tombstone_fn = outRows.getInt64(row, PgColumn.fn) - val tombstone_version = outRows.getInt64(row, PgColumn.version) + val tombstone_fn = outRows.getInt64(row, FN) + val tombstone_version = outRows.getInt64(row, VERSION) val tn = if (tombstone_fn != null && tombstone_version != null) { - TupleNumber(storageNumber, mapNumber, collectionNumber, tombstone_fn, Version(tombstone_version)) + TupleNumber(storageNumber, catalogNumber, collectionNumber, tombstone_fn, tombstone_version) } else null write.tupleNumber = tn @@ -199,7 +209,7 @@ ${if (purge) "LEFT JOIN head_deleted ON head_deleted.id = query.id" else ""} if (select_fn == null || select_version == null) { if (write.version != null) { throw featureNotFound( - "Expected feature '$id' in version '${write.version}', but no such feature exists" + "Expected feature '$fn' in version '${write.version}', but no such feature exists" ) } continue @@ -207,7 +217,7 @@ ${if (purge) "LEFT JOIN head_deleted ON head_deleted.id = query.id" else ""} val head_version = outRows.getInt64(row, "head_version") if (head_version == null || select_version != head_version) { throw conflict( - "The feature '$id' was expected in version '${write.version}', but found in '${Version(select_version)}'" + "The feature '$fn' was expected in version '${write.version}', but found in '${Version(select_version)}'" ) } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterInsert.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterInsert.kt index d6ccee787e..5cf107eb90 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterInsert.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterInsert.kt @@ -3,7 +3,12 @@ package naksha.psql import naksha.base.Platform import naksha.base.Platform.PlatformCompanion.logger import naksha.base.PlatformUtil +import naksha.model.illegalState +import naksha.model.objects.StandardMembers import naksha.model.objects.StoreMode +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.NEXT_VERSION +import naksha.psql.PgColumn.PgColumn_C.VERSION /** * Execute an **INSERT** _(aka [CREATE][naksha.model.request.WriteOp.CREATE])_ into a collection. @@ -16,82 +21,69 @@ import naksha.model.objects.StoreMode * @since 3.0 * @see [PgWriter] */ -internal class PgWriterInsert(writer: PgWriter, collection: PgCollection, partition: Int, writes: List) - : PgWriterBase(writer, collection, partition, writes) -{ +internal class PgWriterInsert( + pgWriter: PgWriter, + pgCollection: PgCollection, + pgWrites: List, + start: Int, + end: Int +) : PgWriterBase(pgWriter, pgCollection, pgWrites, start, end) { + init { - // Transactions HEAD is the one HEAD partitioned by `next_version` and must include the column. - val targetColumns = if (collection.headTable.partitionByColumn == PgColumn.next_version) - collection.effectiveHistoryColumns else collection.effectiveHeadColumns - inRows.addColumns(targetColumns) - val members = collection.head.members - inRows.addCustomMembers(members) - var i = 0 - for (write in writes) { - val tuple = write.tuple - if (tuple != null) { - inRows[i] = tuple - inRows.setCustomMembers(i, write.feature, members) - i++ - } - } + inRows.addColumns(pgCollection.columns) + loadAllTuple() } - private fun plan(conn: PgConnection, collection: PgCollection): PgWriterPlan { - val insert_into_history = if (historyTable != null && collection.head.storeHistory == StoreMode.ON) historyTable else null - + private fun plan(conn: PgConnection): PgWriterPlan { val new_row = """WITH new_row AS ( - SELECT * FROM UNNEST(${inRows.placeholders()}) AS t(${inRows.names()}) + SELECT * FROM UNNEST(${inRows.placeholders()}) AS t(${inRows.aliases()}) )""" // Detect any existing tombstone in HEAD for the same id (auto-purge target). - val effectiveHead = collection.effectiveHeadColumns val head_tombstone = """, head_tombstone AS ( - SELECT ${effectiveHead.joinToString(", ") { "head.${it.name} AS ${it.name}" }} - FROM ${headTable.quotedName} AS head - JOIN new_row ON head.id = new_row.id - WHERE (head.version & 3) >= 2 + SELECT ${pgCollection.joinColumns { column -> "head.$column AS $column" }} + FROM $headIdent AS head + JOIN new_row ON head.$FN = new_row.$FN + WHERE (head.$VERSION & 3) >= 2 )""" // Archive the tombstone into history so the deletion is preserved in the audit trail. // next_version = new feature's version (the tombstone is succeeded by the new creation). - val effCopyHistory = collection.effectiveCopyIntoHistoryColumns - val tombstone_to_history = if (insert_into_history != null) """, tombstone_to_history AS ( - INSERT INTO ${insert_into_history.quotedName} (${PgColumn.next_version}, ${effCopyHistory.joinToString(",") { it.name }}) - SELECT new_row.version AS ${PgColumn.next_version}, - ${effCopyHistory.joinToString(", ") { "head_tombstone.${it.name} AS ${it.name}" }} + val tombstone_to_history = if (historyTable != null) """, tombstone_to_history AS ( + INSERT INTO $historyIdent ($NEXT_VERSION, ${pgCollection.joinColumns { column -> if (NEXT_VERSION eq column) null else column.ident }}) + SELECT new_row.$VERSION AS $NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (NEXT_VERSION eq column) null else "head_tombstone.$column" }} FROM head_tombstone - JOIN new_row ON new_row.id = head_tombstone.id - RETURNING id, fn, version + JOIN new_row ON new_row.$FN = head_tombstone.$FN + RETURNING $FN, $VERSION )""" else "" - // Overwrite the tombstone in HEAD with the new feature (UPDATE in-place, keeps the same fn). + // Overwrite the tombstone in HEAD (except for fn) with the new feature (UPDATE in-place). // All columns are replaced; cc resets to 1 for the new lifecycle. val head_overwrite = """, head_overwrite AS ( - UPDATE ${headTable.quotedName} - SET ${effectiveHead.filter { it !== PgColumn.fn }.joinToString(", ") { "${it.name} = new_row.${it.name}" }} + UPDATE $headIdent + SET ${pgCollection.joinColumns { column -> if (column eq FN) null else "$column = new_row.$column" }} FROM new_row - JOIN head_tombstone ON head_tombstone.id = new_row.id - WHERE ${headTable.quotedName}.fn = head_tombstone.fn - RETURNING ${headTable.quotedName}.id, ${headTable.quotedName}.fn, ${headTable.quotedName}.version + JOIN head_tombstone ON head_tombstone.$FN = new_row.$FN + WHERE $headIdent.$FN = head_tombstone.$FN + RETURNING $headIdent.$FN, $headIdent.$VERSION )""" // Plain INSERT for features that have no tombstone in HEAD (the normal case). val head_inserted = """, head_inserted AS ( - INSERT INTO ${headTable.quotedName} (${inRows.names()}) + INSERT INTO $headIdent (${inRows.aliases()}) SELECT * FROM new_row - WHERE new_row.id NOT IN (SELECT id FROM head_tombstone) - RETURNING id, fn, version + WHERE new_row.$FN NOT IN (SELECT $FN FROM head_tombstone) + RETURNING $FN, $VERSION )""" val SQL = """$new_row$head_tombstone${tombstone_to_history}$head_overwrite$head_inserted SELECT - COALESCE(head_overwrite.id, head_inserted.id) AS id, - COALESCE(head_overwrite.fn, head_inserted.fn) AS fn, - COALESCE(head_overwrite.version, head_inserted.version) AS version + COALESCE(head_overwrite.$FN, head_inserted.$FN) AS $FN, + COALESCE(head_overwrite.$VERSION, head_inserted.$VERSION) AS $VERSION FROM new_row -LEFT JOIN head_overwrite ON head_overwrite.id = new_row.id -LEFT JOIN head_inserted ON head_inserted.id = new_row.id +LEFT JOIN head_overwrite ON head_overwrite.$FN = new_row.$FN +LEFT JOIN head_inserted ON head_inserted.$FN = new_row.$FN """ val typeNames = inRows.typeNames() val pgPlan = conn.prepare(SQL, typeNames) @@ -99,8 +91,8 @@ LEFT JOIN head_inserted ON head_inserted.id = new_row.id } override fun doExecute(conn: PgConnection) { - if (writes.isEmpty()) return - val plan = plan(conn, collection) + if (pgWrites.isEmpty()) return + val plan = plan(conn) val array = inRows.values() if (PlatformUtil.ENABLE_INFO) { if (session.logQueries) { @@ -116,7 +108,7 @@ LEFT JOIN head_inserted ON head_inserted.id = new_row.id plan.pgPlan.execute(array).close() val end = Platform.currentNanos() val seconds = (end.toDouble() - start.toDouble()) / 1e9 - if (writes.size != 1 || writes[0].isFeatureModification) { + if (pgWrites.size != 1 || pgWrites[0].isFeatureModification) { logger.info("INSERT of ${inRows.size} rows took ${seconds * 1000}ms, therefore ${inRows.size / seconds} features/s, partitions: $featureCountByPartitionJoined") } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpdate.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpdate.kt index c6e5b661e9..3eda03b3b9 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpdate.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpdate.kt @@ -1,126 +1,115 @@ package naksha.psql +import naksha.base.Int64 import naksha.base.Platform import naksha.base.Platform.PlatformCompanion.logger import naksha.base.PlatformUtil +import naksha.jbon.HeapBook import naksha.model.* -import naksha.model.objects.StoreMode +import naksha.model.objects.MemberType +import naksha.model.objects.StandardMembers +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.NEXT_VERSION +import naksha.psql.PgColumn.PgColumn_C.VERSION /** * Execute a [UPDATE][naksha.model.request.WriteOp.UPDATE]. * @since 3.0 * @see [PgWriter] */ -internal class PgWriterUpdate(writer: PgWriter, collection: PgCollection, partition: Int, writes: List) - : PgWriterBase(writer, collection, partition, writes) -{ - private val writeById = mutableMapOf() +internal class PgWriterUpdate( + pgWriter: PgWriter, + pgCollection: PgCollection, + pgWrites: List, + start: Int, + end: Int +) : PgWriterBase(pgWriter, pgCollection, pgWrites, start, end) { + // All columns that are BYTE_ARRAYs (can be empty) + private val byteArrayCols = pgCollection.columns.filter { it.memberType == MemberType.BYTE_ARRAY } + private val writeByFn = mutableMapOf() init { - inRows.addColumns(collection.effectiveHeadColumns) - // Separate column for the expected/atomic version, because `version` itself - // is now a real HEAD column carrying the new tuple's version. - inRows.addColumn("expected_version", PgType.INT64) // needed to do atomic updates - val members = collection.head.members - inRows.addCustomMembers(members) - var i = 0 - for (write in writes) { - val tuple = write.tuple - if (tuple != null) { - writeById[write.id] = write - inRows[i] = tuple - inRows.set(i, "expected_version", write.version?.txn) - inRows.setCustomMembers(i, write.feature, members) - i++ - } + inRows.addColumns(pgCollection.columns) + inRows.addColumn("expected_version", MemberType.INT64) // needed to do atomic updates + loadAllTuple { row, tuple, pgWrite -> + writeByFn[tuple.tupleNumber.featureNumber] = pgWrite + // Separate column for the expected HEAD version. + inRows.set(row, "expected_version", pgWrite.version?.number) } } - private fun plan(conn: PgConnection, collection: PgCollection): PgWriterPlan { - val insert_into_history = if (historyTable != null && collection.head.storeHistory == StoreMode.ON) historyTable else null - + private fun plan(conn: PgConnection): PgWriterPlan { // All input provided by client (the updates) val query = """WITH new_row AS ( - SELECT * FROM UNNEST(${inRows.placeholders()}) AS t(${inRows.names()}) + SELECT * FROM UNNEST(${inRows.placeholders()}) AS t(${inRows.aliases()}) )""" - val effHead = collection.effectiveHeadColumns - val effCopyHistory = collection.effectiveCopyIntoHistoryColumns - - // select `id` and version of all rows that match new_row.id - val head_select = """, head_select AS ( - SELECT head.id AS id, head.fn AS fn, head.version AS version - FROM ${headTable.quotedName} AS head, new_row - WHERE head.id = new_row.id + // Select rows from HEAD that we want to update, lock the rows for update + val existing_rows = """, existing_rows AS ( + SELECT head.$FN AS $FN, head.$VERSION AS $VERSION, + FROM $headIdent AS head, new_row + WHERE head.$FN = new_row.$FN + FOR UPDATE NOWAIT )""" - val effHeadNames = effHead.joinToString(", ") { it.name } - // All nullable BYTE_ARRAY columns support the "keep if undefined" sentinel. - // `feature` is mandatory (NOT NULL) and never carries the sentinel. - val keepableByteCols = effHead.filter { it.type == PgType.BYTE_ARRAY && it !== PgColumn.feature } - - // If the client requested an atomic update, so it provided an `expected_version`, then - // we only update the head row, when the version matches. - // If we need to create a history entry, select all HEAD columns, otherwise the minimal set - // needed: id/fn/version for control flow, plus all keepable BYTE_ARRAY columns so the CASE - // expressions in `inserted` can reference head_row.. - val leanHeadRowCols = (listOf(PgColumn.id, PgColumn.fn, PgColumn.version) + keepableByteCols).distinct() + // Select all rows from HEAD that we want to update AND that have the correct version. val head_row = """, head_row AS ( - SELECT ${if (insert_into_history != null) - effHead.joinToString(", ") { "head.${it.name} AS ${it.name}" } - else leanHeadRowCols.joinToString(", ") { "head.${it.name} AS ${it.name}" }} - FROM ${headTable.quotedName} AS head, new_row - WHERE head.id = new_row.id AND (new_row.expected_version IS NULL OR (new_row.expected_version & -4) = (head.version & -4)) - FOR UPDATE NOWAIT + SELECT ${pgCollection.joinColumns { column -> "head.$column" }} + FROM $headIdent AS head, new_row + WHERE head.$FN = new_row.$FN AND (new_row.expected_version IS NULL OR (new_row.expected_version & -4) = (head.$VERSION & -4)) )""" - // Insert the current `head_row` into history. The new tuple's version becomes the demoted row's next_version. - val head_to_history = if (insert_into_history != null) """, head_to_history AS ( - INSERT INTO ${insert_into_history.quotedName} (${PgColumn.next_version}, ${effCopyHistory.joinToString(",") { it.name }}) - SELECT new_row.version AS ${PgColumn.next_version}, - ${effCopyHistory.joinToString(", ") { "head_row.${it.name} AS ${it.name}" }} + // Copy the current HEAD row into HISTORY; set the next version to the version for the history row. + val head_to_history = if (historyTable != null) """, head_to_history AS ( + INSERT INTO $historyIdent ($NEXT_VERSION, ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else column.ident }}) + SELECT new_row.$VERSION AS $NEXT_VERSION, ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else "head_row.$column" }}) FROM head_row - LEFT JOIN new_row ON new_row.id = head_row.id - RETURNING id, fn, version + LEFT JOIN new_row ON new_row.$FN = head_row.$FN + RETURNING $FN, $VERSION )""" else "" - // Delete `head_row` from HEAD. + // Delete HEAD rows that have been copied into history. val head_deleted = """, head_deleted AS ( - DELETE FROM ${headTable.quotedName} - WHERE (fn, version) IN (SELECT fn, version FROM head_row) - RETURNING id, fn, version + DELETE FROM $headIdent + WHERE $FN IN (SELECT $FN FROM head_row) + RETURNING $FN, $VERSION )""" val inserted = """, inserted AS ( -INSERT INTO ${headTable.quotedName} ($effHeadNames) -SELECT ${effHead.joinToString(", ") { col -> - if (col in keepableByteCols) - "CASE WHEN new_row.${col.name} = convert_to('undefined', 'UTF8') THEN head_row.${col.name} ELSE new_row.${col.name} END AS ${col.name}" - else "new_row.${col.name} AS ${col.name}" - }} +INSERT INTO $headIdent ($NEXT_VERSION, ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else column.ident }}) +SELECT NULL AS $NEXT_VERSION, ${pgCollection.joinColumns { column -> + if (column eq NEXT_VERSION) + null + else if (column.memberType == MemberType.BYTE_ARRAY) + "CASE WHEN new_row.$column = convert_to('undefined', 'UTF8') THEN head_row.$column ELSE new_row.$column END AS $column" + else + "head_row.$column" +}}) FROM new_row -LEFT JOIN head_row ON head_row.id = new_row.id -RETURNING id, fn, version${if (keepableByteCols.isNotEmpty()) keepableByteCols.joinToString("") { ", ${it.name}" } else ""} +LEFT JOIN head_row ON head_row.$FN = new_row.$FN +RETURNING $FN, $VERSION${if (byteArrayCols.isNotEmpty()) ", ${byteArrayCols.joinToString(", ") { column -> + // We return NULL, when the input contained data, because the client knows this already, no need to send back. + // If the client provided `undefined`, we return the actual value so we can build a correct tuple. + "CASE WHEN new_row.$column = convert_to('undefined', 'UTF8') THEN $column ELSE null END AS $column" + }}" else ""} )""" - val SQL = """$query$head_select$head_row$head_to_history$head_deleted$inserted + val SQL = """$query$existing_rows$head_row$head_to_history$head_deleted$inserted SELECT - new_row.id AS id, - new_row.fn AS fn, - new_row.version AS version, - head_select.id AS existing_id, - head_select.version AS existing_version, - head_row.id AS head_id, - ${if (head_to_history.isNotEmpty()) "head_to_history.id AS history_id," else ""} - head_deleted.id AS head_deleted_id, - inserted.id AS inserted_id, - ${if (keepableByteCols.isNotEmpty()) keepableByteCols.joinToString(",\n ") { "inserted.${it.name} AS ${it.name}" } else "null::bytea AS attachment"} + new_row.$FN AS $FN, + new_row.$VERSION AS $VERSION, + existing_rows.$FN AS _existing_fn, + existing_rows.$VERSION AS _existing_version, + ${if (byteArrayCols.isNotEmpty()) byteArrayCols.joinToString(",\n ") { column -> "inserted.$column AS $column" } + ",\n " else ""} + ${if (head_to_history.isNotEmpty()) "head_to_history.$FN AS _history_fn," else "NULL AS _history_fn"} + head_deleted.$FN AS _head_deleted_fn, + inserted.$FN AS _inserted_fn FROM new_row -LEFT JOIN head_select ON head_select.id = new_row.id -LEFT JOIN head_row ON head_row.id = new_row.id -${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_history.id = new_row.id" else ""} -LEFT JOIN head_deleted ON head_deleted.id = new_row.id -LEFT JOIN inserted ON inserted.id = new_row.id +LEFT JOIN existing_rows ON existing_rows.$FN = new_row.$FN +LEFT JOIN head_row ON head_row.$FN = new_row.$FN +${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_history.$FN = new_row.$FN" else ""} +LEFT JOIN head_deleted ON head_deleted.$FN = new_row.$FN +LEFT JOIN inserted ON inserted.$FN = new_row.$FN ;""" val typeNames = inRows.typeNames() val pgPlan = conn.prepare(SQL, typeNames) @@ -128,20 +117,23 @@ LEFT JOIN inserted ON inserted.id = new_row.id } override fun doExecute(conn: PgConnection) { - if (writes.isEmpty()) return + if (pgWrites.isEmpty()) return // All nullable BYTE_ARRAY columns may carry the "keep if undefined" sentinel and must be // read back from the DB so the in-memory tuple reflects the final stored value. - val keepableByteCols = collection.effectiveHeadColumns.filter { it.type == PgType.BYTE_ARRAY && it !== PgColumn.feature } - val rows = PgColumnRows() - .withStorageNumber(storageNumber) - .withMapNumber(mapNumber) + val outRows = PgRows() + .withDatabaseNumber(storageNumber) + .withCatalogNumber(catalogNumber) .withCollectionNumber(collectionNumber) - .addColumn("id", PgType.STRING) - .addColumn("existing_id", PgType.STRING) - .addColumn("existing_version", PgType.INT64) - .addColumn("head_id", PgType.STRING) - for (col in keepableByteCols) rows.addColumn(col.name, PgType.BYTE_ARRAY) - val plan = plan(conn, collection) + outRows.addColumn(FN) + .addColumn(VERSION) + .addColumn("_existing_fn", MemberType.INT64) + .addColumn("_existing_version", MemberType.INT64) + for (column in byteArrayCols) outRows.addColumn(column) + outRows.addColumn("_history_fn", MemberType.INT64) + outRows.addColumn("_head_deleted_fn", MemberType.INT64) + outRows.addColumn("_inserted_fn", MemberType.INT64) + + val plan = plan(conn) val array = this.inRows.values() if (PlatformUtil.ENABLE_INFO) { if (session.logQueries) { @@ -156,53 +148,58 @@ LEFT JOIN inserted ON inserted.id = new_row.id val cursor = plan.pgPlan.execute(array) val end = Platform.currentNanos() val seconds = (end.toDouble() - start.toDouble()) / 1e9 - if (writes.size != 1 || writes[0].isFeatureModification) { - logger.info("UPDATE of ${rows.size} rows took ${seconds * 1000}ms, therefore ${rows.size / seconds} features/s, partitions: $featureCountByPartitionJoined") + if (pgWrites.size != 1 || pgWrites[0].isFeatureModification) { + logger.info("UPDATE of ${outRows.size} rows took ${seconds * 1000}ms, therefore ${outRows.size / seconds} features/s, partitions: $featureCountByPartitionJoined") } cursor.fetch().use { - rows.addAll(cursor) - for (rowNum in 0 until rows.size) { - // The original `id` of the feature to update. - val id = rows.getString(rowNum, "id") ?: throw illegalState("Column 'id' in result must not be null") - // The `id` and `tuple-number` currently in HEAD table. - val existing_id = rows.getString(rowNum, "existing_id") - if (existing_id != id) { - throw featureNotFound("Failed to update feature '$id', no such feature exists") + outRows.readAll(cursor) + for (row in 0 until outRows.size) { + val fn = outRows.getInt64(row, FN) ?: throw illegalState("Column '$FN' in result must not be null") + val version = outRows.getInt64(row, VERSION) ?: throw illegalState("Column '$VERSION' in result must not be null") + val newTn = TupleNumber(storageNumber, catalogNumber, collectionNumber, fn, version) + val pgWrite = writeByFn[fn] ?: throw illegalState("Missing write record for feature-number: $fn") + val expected_version: Int64? = pgWrite.version?.number + + // Feature should have existed. + val existing_fn = outRows.getInt64(row, "_existing_fn") + ?: throw featureNotFound("Failed to update feature '${pgWrite.id}', no such feature exists") + if (existing_fn != fn) { + // We do not expect this to ever happen! + throw generalException("Internal error, feature-number mismatch for feature '${pgWrite.id}', expected fn: $fn, existing fn: $existing_fn") } - val existing_version = rows.getInt64(rowNum, "existing_version") ?: throw illegalState("Missing version in HEAD select for feature '$id'") - // Fetch the original write and tuple for this row. - val write = writeById[id] ?: throw illegalState("Missing write state for feature '$id'") - val tuple = write.tuple ?: throw generalException("Missing tuple for feature '$id'") - // The `id` from the eventually read head-row, this is only available, if the existing_id is the expected version! - val head_id = rows.getString(rowNum, "head_id") - if (head_id != id) { // Conflict! - val expectedVersion = write.version ?: throw illegalState("Missing expected version for feature '$id'") - throw conflict("The feature '$id' was expected in version $expectedVersion, but actually found in ${Version(existing_version)}") + val existing_version = outRows.getInt64(row, "_existing_version") + // We do not expect this to ever happen, when we have an existing_fn there must be as well an existing_version! + ?: throw generalException("Internal error, missing existing version for feature '${pgWrite.id}' in result-set") + val previousTupleNumber = TupleNumber(storageNumber, catalogNumber, collectionNumber, existing_fn, existing_version) + + // We should have updated the feature + val inserted_fn = outRows.getInt64(row, "_inserted_fn") ?: { + // The only defined reason is that the expected version did not match. + if (expected_version != null && (expected_version and Int64(-4)) != (existing_version and Int64(-4))) { + throw conflict("Atomic update failed, feature '${pgWrite.id}' was expected in version $existing_version, but found to be in $existing_version" ) + } + // Otherwise, there is an internal error. + throw generalException("Internal error, failed to update feature '${pgWrite.id}', update was skipped for unknown reason") } - // Patch back all BYTE_ARRAY columns whose stored value may differ from what the client sent - // (sentinel "undefined" causes the DB to retain the existing value). - val geo = if (PgColumn.geo in keepableByteCols) rows.getByteArray(rowNum, PgColumn.geo.name) else tuple.getByteArray(naksha.model.objects.StandardMembers.Geometry) - val referencePoint = if (PgColumn.ref_point in keepableByteCols) rows.getByteArray(rowNum, PgColumn.ref_point.name) else tuple.getByteArray(naksha.model.objects.StandardMembers.ReferencePoint) - val tags = tuple.getStringMember(naksha.model.objects.StandardMembers.Tags) - val attachment = if (PgColumn.attachment in keepableByteCols) rows.getByteArray(rowNum, PgColumn.attachment.name) else tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment) - val oldGeo = tuple.getByteArray(naksha.model.objects.StandardMembers.Geometry) - val oldRefPoint = tuple.getByteArray(naksha.model.objects.StandardMembers.ReferencePoint) - val oldAttachment = tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment) - val needsPatch = (oldGeo == null || !oldGeo.contentEquals(geo ?: ByteArray(0))) - || (oldRefPoint == null || !oldRefPoint.contentEquals(referencePoint ?: ByteArray(0))) - || (oldAttachment == null || !oldAttachment.contentEquals(attachment ?: ByteArray(0))) - if (needsPatch) { - val m = tuple.members - val newMembers = if (m is naksha.jbon.HeapBook) { - val dict = m.copy() - dict.put("geo", geo) - dict.put("ref_point", referencePoint) - dict.put("tags", tags) - dict.put("attachment", attachment) - dict - } else m - write.tuple = tuple.copy(members = newMembers) + + val tuple = pgWrite.tuple ?: throw generalException("Missing tuple for feature '${pgWrite.id}}'") + val memberBook = tuple.membersBook + val updatedMembersBook = HeapBook.copyOf(memberBook) + // TODO: Fix change-count ! + // if (CC != null) updatedMembersBook.put(CC.name, change_count) + updatedMembersBook.put(StandardMembers.Tn.name, newTn) + // Update all BYTE_ARRAY members that have been updated. + for (column in byteArrayCols) { + val inValue = tuple.membersBook[column.name] as ByteArray? + val newValue = if (inValue == null || UNDEFINED.contentEquals(inValue)) outRows.getByteArray(row, column) else inValue + updatedMembersBook.put(column.name, newValue) } + val updatedTuple = tuple.copy( + membersBook = updatedMembersBook, + previousTupleNumber = previousTupleNumber + ) + pgWrite.tuple = updatedTuple + pgWrite.tupleNumber = newTn } } } diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpsert.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpsert.kt index 24a1070539..89d26e0528 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpsert.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/PgWriterUpsert.kt @@ -1,169 +1,177 @@ package naksha.psql +import naksha.base.Int64 import naksha.base.Platform import naksha.base.Platform.PlatformCompanion.logger import naksha.base.PlatformUtil +import naksha.jbon.HeapBook import naksha.model.* -import naksha.model.objects.StoreMode +import naksha.model.objects.MemberType +import naksha.model.objects.StandardMembers +import naksha.psql.PgColumn.PgColumn_C.FN +import naksha.psql.PgColumn.PgColumn_C.NEXT_VERSION +import naksha.psql.PgColumn.PgColumn_C.VERSION /** * Execute [UPSERT][naksha.model.request.WriteOp.UPSERT] into a collection. * @since 3.0 * @see [PgWriter] */ -internal class PgWriterUpsert(writer: PgWriter, collection: PgCollection, partition: Int, writes: List) - : PgWriterBase(writer, collection, partition, writes) -{ - private val writeByTn = mutableMapOf() +internal class PgWriterUpsert( + pgWriter: PgWriter, + pgCollection: PgCollection, + pgWrites: List, + start: Int, + end: Int +) : PgWriterBase(pgWriter, pgCollection, pgWrites, start, end) { + // All columns that are BYTE_ARRAYs (can be empty) + private val byteArrayCols = pgCollection.columns.filter { it.memberType == MemberType.BYTE_ARRAY } + private val writeByFn = mutableMapOf() init { - inRows.addColumns(collection.effectiveHeadColumns) - val members = collection.head.members - inRows.addCustomMembers(members) - var i = 0 - for (write in writes) { - val tuple = write.tuple - if (tuple != null) { - inRows[i] = tuple - inRows.setCustomMembers(i, write.feature, members) - writeByTn[tuple.tupleNumber] = write - i++ - } - } + inRows.addColumns(pgCollection.columns) + loadAllTuple { _, tuple, pgWrite -> writeByFn[tuple.tupleNumber.featureNumber] = pgWrite } } - private fun plan(conn: PgConnection, collection: PgCollection): PgWriterPlan { - val insert_into_history = if (historyTable != null && collection.head.storeHistory == StoreMode.ON) historyTable else null - + private fun plan(conn: PgConnection): PgWriterPlan { // This is what we should INSERT or UPDATE. val new_row = """WITH new_row AS ( - SELECT * FROM UNNEST(${inRows.placeholders()}) AS t(${inRows.names()}) + SELECT * FROM UNNEST(${inRows.placeholders()}) AS t(${inRows.aliases()}) )""" // Select existing. val head_row = """, head_row AS ( - SELECT * FROM ${headTable.quotedName} - WHERE fn IN (SELECT fn FROM new_row) + SELECT * FROM $headIdent + WHERE $FN IN (SELECT $FN FROM new_row) )""" - // Insert the current `head_row` into history. next_version is the new tuple's version with action set to UPDATE. - val effCopyHistory = collection.effectiveCopyIntoHistoryColumns - val effUpdate = collection.effectiveUpdateColumns - val head_to_history = if (insert_into_history != null) """, head_to_history AS ( - INSERT INTO ${insert_into_history.quotedName} (${PgColumn.next_version}, ${effCopyHistory.joinToString(",") { it.name }}) - SELECT ((new_row.version & -4) | 1) AS ${PgColumn.next_version}, - ${effCopyHistory.joinToString(", ") { "head_row.${it.name} AS ${it.name}" }} + // Copy all current head row's into history that are in CREATE or UPDATE action. + // We need to set `next_version` to the new tuple's version, which is new row's version plus action UPDATE. + val head_to_history = if (historyTable != null) """, head_to_history AS ( + INSERT INTO $historyIdent ($NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else column.ident }}) + SELECT ((new_row.$VERSION & -4) | 1) AS $NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else "head_row.$column AS $column" }} + FROM head_row + LEFT JOIN new_row ON new_row.$FN = head_row.$FN + WHERE (head_row.$VERSION & -4) < 2 -- action = CREATE or UPDATE + RETURNING $FN, $VERSION${if (byteArrayCols.isNotEmpty()) ", ${byteArrayCols.joinToString(", ") { column -> + // We return NULL, when the input contained data, because the client knows this already, no need to send back. + // If the client provided `undefined`, we return the actual value so we can build a correct tuple. + "CASE WHEN new_row.$column = convert_to('undefined', 'UTF8') THEN $column ELSE null END AS $column" + }}" else ""} +)""" else "" + + // Copy all current head row's into history that are in DELETE action. + // In this case, we need to set `next_version` to the old tuple's version to signal end of lifetime. + val tombstone_to_history = if (historyTable != null) """, tombstone_to_history AS ( + INSERT INTO $historyIdent ($NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else column.ident }}) + SELECT head_row.$VERSION AS $NEXT_VERSION, + ${pgCollection.joinColumns { column -> if (column eq NEXT_VERSION) null else "head_row.$column AS $column" }} FROM head_row - LEFT JOIN new_row ON new_row.fn = head_row.fn - RETURNING id, fn, version + LEFT JOIN new_row ON new_row.$FN = head_row.$FN + WHERE (head_row.$VERSION & -4) == 2 -- action = DELETE + RETURNING $FN, $VERSION )""" else "" // Delete `head_row` from HEAD. val head_deleted = """, head_deleted AS ( - DELETE FROM ${headTable.quotedName} - WHERE fn IN (SELECT fn FROM head_row) - RETURNING id, fn, version + DELETE FROM $headIdent + WHERE $FN IN (SELECT $FN FROM head_row) + RETURNING $FN, $VERSION )""" - // All nullable BYTE_ARRAY columns support the "keep if undefined" sentinel. - // `feature` is mandatory (NOT NULL) and never carries the sentinel. - val keepableByteCols = collection.effectiveHeadColumns.filter { it.type == PgType.BYTE_ARRAY && it !== PgColumn.feature } - - // Insert new rows for which there was no existing HEAD version. + // Copy new rows for which there was no existing HEAD version or HEAD was in action DELETE. // Sentinel "undefined" on any BYTE_ARRAY column is treated as NULL on insert (no prior value to retain). - val head_inserted = """, head_inserted AS ( - INSERT INTO ${headTable.quotedName} (${inRows.names()}) - SELECT ${inRows.columns.joinToString(", ") { col -> - val q = PgUtil.quoteIdent(col.name) - if (keepableByteCols.any { it.name == col.name }) - "CASE WHEN ${col.name} = convert_to('undefined', 'UTF8') THEN null ELSE ${col.name} END AS ${col.name}" - else - q - }} FROM new_row - WHERE new_row.fn NOT IN (SELECT fn FROM head_deleted) - RETURNING id, fn, version -)""" + // Note: We expact that the ASCII string `undefined` is put into the BYTE_ARRAY, when it should be undefined. + // That is why we check for `CASE WHEN $ident = convert_to('undefined', 'UTF8') ...` - // Update means insert new_rows, but with patched values. - // For any BYTE_ARRAY column that carries the sentinel, keep the value from the existing HEAD row. - // The action is encoded in the lower two bits of `version`; we set it to UPDATED (=1). - // Only include columns that exist in the table (effectiveUpdateColumns). - val hasCc = PgColumn.cc in collection.effectiveHeadColumns - val updColNames = effUpdate.joinToString(",") { it.name } - // keepable cols that are NOT in effUpdate need their own INSERT slot (currently: attachment) - val keepableExtraCols = keepableByteCols.filter { it !in effUpdate } - val keepableExtraColNames = keepableExtraCols.joinToString(",") { it.name } + // ------------------------------------------ DO UPDATE -------------------------------------------------------- val head_updated = """, head_updated AS ( - INSERT INTO ${headTable.quotedName} ( - ${if (hasCc) "${PgColumn.cc}," else ""} - ${if (keepableExtraCols.isNotEmpty()) "$keepableExtraColNames," else ""} - ${PgColumn.fn}, - ${PgColumn.version}${if (updColNames.isNotEmpty()) ",\n $updColNames" else ""}) - SELECT - ${if (hasCc) "(head_row.cc + 1) AS ${PgColumn.cc}," else ""} - ${if (keepableExtraCols.isNotEmpty()) keepableExtraCols.joinToString(", ") { col -> - "CASE WHEN new_row.${col.name} = convert_to('undefined', 'UTF8') THEN head_row.${col.name} ELSE new_row.${col.name} END AS ${col.name}" - } + "," else ""} - new_row.fn AS ${PgColumn.fn}, - ((new_row.version & -4) | 1) AS ${PgColumn.version}${if (effUpdate.isNotEmpty()) ",\n ${effUpdate.joinToString(", ") { col -> - if (col in keepableByteCols) - "CASE WHEN new_row.${col.name} = convert_to('undefined', 'UTF8') THEN head_row.${col.name} ELSE new_row.${col.name} END AS ${col.name}" - else - "new_row.${col.name} AS ${col.name}" - }}" else ""} - FROM new_row - LEFT JOIN head_row ON head_row.fn = new_row.fn - WHERE new_row.fn IN (SELECT fn FROM head_deleted) - RETURNING id, fn, version${if (hasCc) ", cc" else ""}${keepableByteCols.joinToString("") { ", ${it.name}" }} -)""" + INSERT INTO $headIdent (${inRows.aliases()}) + SELECT ${inRows.columns.joinToString(", ") { colWithValue -> + val ident = PgUtil.quoteIdent(colWithValue.alias) + val pgColumn = colWithValue.pgColumn + if (pgColumn eq CC) { + "(head_to_history.$CC + 1) AS $CC" // Increment Change-Count + } else if (pgColumn eq VERSION){ + "((new_row.$VERSION & -4) | 1) AS $VERSION" // Set lower 2 bit to UPDATE (1) + } else if (pgColumn eq NEXT_VERSION){ + "NULL AS $NEXT_VERSION" // in HEAD, next version must always be NULL + } else if (pgColumn.memberType == MemberType.BYTE_ARRAY) { + "CASE WHEN $ident = convert_to('undefined', 'UTF8') THEN head_to_history.$ident ELSE new_row.$ident END AS $ident" + } else ident + }} FROM new_row + LEFT JOIN head_to_history ON new_row.$FN = head_to_history.$FN + WHERE new_row.$FN IN (SELECT $FN FROM head_to_history) + RETURNING $FN, $VERSION +)""" // Note: head_to_history contains all existing HEAD row in CREATE or UPDATE action. - val SQL = """$new_row$head_row$head_deleted$head_to_history$head_inserted$head_updated + // ------------------------------------------ DO INSERT -------------------------------------------------------- + val head_inserted = """, head_inserted AS ( + INSERT INTO $headIdent (${inRows.aliases()}) + SELECT ${inRows.columns.joinToString(", ") { colWithValue -> + val ident = PgUtil.quoteIdent(colWithValue.alias) + val pgColumn = colWithValue.pgColumn + if (pgColumn eq CC) { + "1 AS $CC" // Change-Count = 1 + } else if (pgColumn eq VERSION){ + "($VERSION & -4) AS $VERSION" // Clear lower 2 bit to set action = CREATE (0) + } else if (pgColumn eq NEXT_VERSION){ + "NULL AS $NEXT_VERSION" // in HEAD, next version must always be NULL + } else if (pgColumn.memberType == MemberType.BYTE_ARRAY) { + "CASE WHEN $ident = convert_to('undefined', 'UTF8') THEN null ELSE $ident END AS $ident" + } else ident +}} FROM new_row + WHERE new_row.$FN NOT IN (SELECT $FN FROM head_updated) + RETURNING $FN, $VERSION +)""" // Note: for the real inserts, we do not need to return byte-array columns, because we send them, so its clear what the content will be. + + val SQL = """$new_row$head_row$head_to_history$tombstone_to_history$head_deleted$head_updated$head_inserted SELECT - new_row.id AS id, - new_row.fn AS fn, - new_row.version AS version, - head_updated.fn AS updated_fn, - head_updated.version AS updated_version, - ${if (hasCc) "head_updated.cc AS cc," else "null::int4 AS cc,"} - ${if (keepableByteCols.isNotEmpty()) keepableByteCols.joinToString(",\n ") { "head_updated.${it.name} AS ${it.name}" } + "," else "null::bytea AS attachment,"} - head_row.version AS head_row_version, - head_deleted.version AS head_deleted_version, - head_inserted.version AS head_inserted_version, - null AS clear_shadow_version, - ${if (head_to_history.isNotEmpty()) "head_to_history.version AS head_to_history_version" else "null AS head_to_history_version"} + new_row.$FN AS $FN, + new_row.$VERSION AS $VERSION, + ${if (byteArrayCols.isNotEmpty()) byteArrayCols.joinToString(",\n ") { column -> "head_updated.$column AS $column" } + ",\n " else ""} + ${if (CC!=null) "head_updated.$CC AS $CC," else "null::int4 AS $CC,"} + head_updated.$FN AS _updated_fn, + head_updated.$VERSION AS _updated_version, + head_row.$VERSION AS _head_row_version, + head_deleted.$VERSION AS _head_deleted_version, + head_inserted.$VERSION AS _head_inserted_version, + ${if (head_to_history.isNotEmpty()) "head_to_history.$VERSION AS _head_to_history_version" else "null AS _head_to_history_version"} FROM new_row -LEFT JOIN head_updated ON head_updated.fn = new_row.fn -LEFT JOIN head_row ON head_row.fn = new_row.fn -LEFT JOIN head_deleted ON head_deleted.fn = new_row.fn -LEFT JOIN head_inserted ON head_inserted.fn = new_row.fn -${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_history.fn = new_row.fn" else ""} +LEFT JOIN head_updated ON head_updated.$FN = new_row.$FN +LEFT JOIN head_row ON head_row.$FN = new_row.$FN +LEFT JOIN head_deleted ON head_deleted.$FN = new_row.$FN +LEFT JOIN head_inserted ON head_inserted.$FN = new_row.$FN +${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_history.$FN = new_row.$FN" else ""} ;""" - val typeNames = inRows.typeNames(); - val pgPlan = conn.prepare(SQL, typeNames); + // Notes: + // head_updated contains all rows that have been executed an UPDATE, all others performed an INSERT + val typeNames = inRows.typeNames() + val pgPlan = conn.prepare(SQL, typeNames) return PgWriterPlan(pgPlan, SQL, typeNames) } override fun doExecute(conn: PgConnection) { - val keepableByteCols = collection.effectiveHeadColumns.filter { it.type == PgType.BYTE_ARRAY && it !== PgColumn.feature } - val outRows = PgColumnRows() - .withStorageNumber(storageNumber) - .withMapNumber(mapNumber) + val outRows = PgRows() + .withDatabaseNumber(storageNumber) + .withCatalogNumber(catalogNumber) .withCollectionNumber(collectionNumber) - .addColumn(PgColumn.id) - .addColumn(PgColumn.fn) - .addColumn(PgColumn.version) - .addColumn(PgColumn.cc) - .addColumn("updated_fn", PgType.INT64) - .addColumn("updated_version", PgType.INT64) - .addColumn("head_row_version", PgType.INT64) - .addColumn("clear_shadow_version", PgType.INT64) - .addColumn("head_deleted_version", PgType.INT64) - .addColumn("head_inserted_version", PgType.INT64) - .addColumn("head_to_history_version", PgType.INT64) - for (col in keepableByteCols) outRows.addColumn(col.name, PgType.BYTE_ARRAY) - if (writes.isEmpty()) return - val plan = plan(conn, collection) - // TupleNumber.fromB128(inRows.columns[11].values_field[0] as ByteArray, naksha.base.Int64(0), 0, 0).partitionNumber % 16 + outRows.addColumn(FN) + .addColumn(VERSION) + for (column in byteArrayCols) outRows.addColumn(column) + if (CC!=null) outRows.addColumn(CC) + outRows.addColumn("_updated_fn", MemberType.INT64) + .addColumn("_updated_version", MemberType.INT64) + .addColumn("_head_row_version", MemberType.INT64) + .addColumn("_head_deleted_version", MemberType.INT64) + .addColumn("_head_inserted_version", MemberType.INT64) + .addColumn("_clear_shadow_version", MemberType.INT64) + .addColumn("_head_to_history_version", MemberType.INT64) + if (pgWrites.isEmpty()) return + val plan = plan(conn) val array = inRows.values() val session = this.session if (PlatformUtil.ENABLE_INFO) { @@ -179,53 +187,45 @@ ${if (head_to_history.isNotEmpty()) "LEFT JOIN head_to_history ON head_to_histor val cursor = plan.pgPlan.execute(array) val end = Platform.currentNanos() val seconds = (end.toDouble() - start.toDouble()) / 1e9 - if (writes.size != 1 || writes[0].isFeatureModification) { + if (pgWrites.size != 1 || pgWrites[0].isFeatureModification) { logger.info("UPSERT of ${inRows.size} rows took ${seconds * 1000}ms, therefore ${inRows.size / seconds} features/s, partitions: $featureCountByPartitionJoined") } cursor.fetch().use { - outRows.addAll(cursor) + outRows.readAll(cursor) for (row in 0 until outRows.size) { - val fn = outRows.getInt64(row, "fn") ?: throw generalException("Missing 'fn' in SQL result") - val id = outRows.getString(row, "id") ?: fn.toString() - val versionTxn = outRows.getInt64(row, "version") ?: throw generalException("Missing 'version' in SQL result") - val tn = TupleNumber(storageNumber, mapNumber, collectionNumber, fn, Version(versionTxn)) - - // We need to patch the tuple of all inserts, that were replaced with updates! - // The content is the same, but the action, operation, and change-count change. - val updatedFn = outRows.getInt64(row, "updated_fn") - val updatedVersionTxn = outRows.getInt64(row, "updated_version") - if (updatedFn != null && updatedVersionTxn != null) { - val updated_tn = TupleNumber(storageNumber, mapNumber, collectionNumber, updatedFn, Version(updatedVersionTxn)) + val updated_fn = outRows.getInt64(row, "_updated_fn") + val updated_version = outRows.getInt64(row, "_updated_version") + if (updated_fn != null && updated_version != null) { + // UPDATE was executed. + val pgWrite = writeByFn[updated_fn] ?: throw generalException("Received _updated_fn '$updated_fn', but found no matching PgWrite") + val updatedTupleNumber = TupleNumber(storageNumber, catalogNumber, collectionNumber, updated_fn, updated_version) + val previousTupleNumber = TupleNumber(storageNumber, catalogNumber, collectionNumber, updated_fn, updated_version) // If an update was done, we need the following values to be available: - val hasCc = PgColumn.cc in collection.effectiveHeadColumns - val changeCount: Int = if (hasCc) { - outRows.getInt(row, "cc") ?: - throw generalException("Missing 'cc' in update result for feature '$id'") + val change_count: Int = if (CC!=null) { + outRows.getInt(row, CC) ?: throw generalException("Missing '$CC' in update result for feature '${pgWrite.id}'") } else 1 - val write = writeByTn[tn] ?: throw generalException("Missing write state for feature '$id'") - val tuple = write.tuple ?: throw generalException("Missing tuple for feature '$id'") - // Read back all keepable BYTE_ARRAY columns — the DB may have substituted the sentinel - // with the existing value, so the in-memory tuple must reflect the final stored state. - val geo = if (PgColumn.geo in keepableByteCols) outRows.getByteArray(row, PgColumn.geo.name) else tuple.getByteArray(naksha.model.objects.StandardMembers.Geometry) - val referencePoint = if (PgColumn.ref_point in keepableByteCols) outRows.getByteArray(row, PgColumn.ref_point.name) else tuple.getByteArray(naksha.model.objects.StandardMembers.ReferencePoint) - val tags = tuple.getStringMember(naksha.model.objects.StandardMembers.Tags) - val attachment = if (PgColumn.attachment in keepableByteCols) outRows.getByteArray(row, PgColumn.attachment.name) else tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment) - write.tupleNumber = updated_tn - val m = tuple.members - val newMembers = if (m is naksha.jbon.HeapBook) { - val dict = m.copy() - dict.put("geo", geo) - dict.put("ref_point", referencePoint) - dict.put("tags", tags) - dict.put("attachment", attachment) - dict - } else m - write.tuple = tuple.copy( - version = updated_tn.version, - members = newMembers + + val insertTuple = pgWrite.tuple ?: throw generalException("Missing tuple for feature '${pgWrite.id}}'") + val insertMemberBook = insertTuple.membersBook + val updatedMembersBook = HeapBook.copyOf(insertMemberBook) + if (CC != null) updatedMembersBook.put(CC.name, change_count) + updatedMembersBook.put(StandardMembers.Tn.name, updatedTupleNumber) + // Update all BYTE_ARRAY members that have been updated. + for (column in byteArrayCols) { + val inValue = insertTuple.membersBook[column.name] as ByteArray? + val newValue = if (inValue == null || UNDEFINED.contentEquals(inValue)) outRows.getByteArray(row, column) else inValue + updatedMembersBook.put(column.name, newValue) + } + val updatedTuple = insertTuple.copy( + membersBook = updatedMembersBook, + previousTupleNumber = previousTupleNumber ) - write.action = Action.UPDATED - } + pgWrite.tuple = updatedTuple + pgWrite.tupleNumber = updatedTupleNumber + } // else INSERT executed, that means the tuple was inserted as given. +// val fn = outRows.getInt64(row, FN) ?: throw generalException("Missing 'fn' in SQL result") +// val new_version = outRows.getInt64(row, VERSION) ?: throw generalException("Missing 'version' in SQL result") +// val new_tn = TupleNumber(storageNumber, catalogNumber, collectionNumber, fn, new_version) } } } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/AttachmentTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/AttachmentTest.kt deleted file mode 100644 index 9359f6471a..0000000000 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/AttachmentTest.kt +++ /dev/null @@ -1,242 +0,0 @@ -package naksha.psql - -import naksha.base.PlatformUtil -import naksha.model.* -import naksha.model.objects.NakshaCollection -import naksha.model.objects.NakshaFeature -import naksha.model.request.* -import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID -import naksha.model.RandomFeatures.RandomFeatures_C.randomFeature -import kotlin.test.* - -class AttachmentTest : PgTestBase() { - - @Test - fun insertFeatureWithAttachment() { - val attachmentOriginal = "this is a test" - val attachmentBytes = attachmentOriginal.encodeToByteArray() - val featureToCreate = randomFeature() - val xyz = featureToCreate.properties.xyz - xyz.tags.clear() - xyz.tags.addTag("wicked", false) - - // Write the feature - val writeFeaturesReq = WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, featureToCreate).withAttachment(attachmentBytes)) - } - executeWrite(writeFeaturesReq).apply { - // Verify the result (will come from cache) - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals(Action.CREATED, feature.properties.xyz.guid?.tupleNumber?.action) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - } - - // Read the feature - Naksha.cache.clear() - executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - featureIds += featureToCreate.id - }).apply { - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals(Action.CREATED, feature.properties.xyz.guid?.tupleNumber?.action) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - } - } - - @Test - fun upsertFeatureWithAttachment() { - val attachmentOriginal = "this is a test" - val attachmentBytes = attachmentOriginal.encodeToByteArray() - val featureId = PlatformUtil.randomString() - val featureToCreate = randomFeature(featureId) - featureToCreate.properties["test"] = "start" - val xyz = featureToCreate.properties.xyz - xyz.tags.clear() - xyz.tags.addTag("wicked", false) - - // Write the feature - val writeFeaturesReq = WriteRequest().apply { - add(Write().upsertFeature(collection.mapId, collection.id, featureToCreate).withAttachment(attachmentBytes)) - } - executeWrite(writeFeaturesReq).apply { - // Verify the result (will come from cache) - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals("start", feature.properties["test"]) - assertEquals(Action.CREATED, feature.properties.xyz.guid?.tupleNumber?.action) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - } - - // Read the feature - Naksha.cache.clear() - val readFeature: NakshaFeature - executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - featureIds += featureToCreate.id - }).apply { - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals("start", feature.properties["test"]) - assertEquals(Action.CREATED, feature.properties.xyz.guid?.tupleNumber?.action) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - - readFeature = feature - } - val insertedFeatureGuid = readFeature.properties.xyz.guid - assertNotNull(insertedFeatureGuid) - assertEquals(featureId, insertedFeatureGuid.id) - assertEquals(storage.number, insertedFeatureGuid.tupleNumber.storageNumber) - assertEquals(map.number, insertedFeatureGuid.tupleNumber.mapNumber) - assertEquals(collection.number, insertedFeatureGuid.tupleNumber.collectionNumber) - - // Now, update the feature, leave the attachment as it is. - // In other words, for this upsert, we do not provide an attachment, but expect to find it in the response! - Naksha.cache.clear() - val upsertFeature: NakshaFeature = readFeature.copy(true) - upsertFeature.properties["test"] = "end" - val updateFeatureReq = WriteRequest().apply { - // We do not modify attachment, therefore it should be UNDEFINED - add(Write().upsertFeature(collection.mapId, collection.id, upsertFeature)) - } - executeWrite(updateFeatureReq).apply { - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals("end", feature.properties["test"]) - assertEquals(Action.UPDATED, feature.properties.xyz.guid?.tupleNumber?.action) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - } - } - - @Test - fun updateFeatureWithAttachment() { - val attachmentOriginal = "this is a test" - val attachmentBytes = attachmentOriginal.encodeToByteArray() - val featureId = PlatformUtil.randomString() - val featureToCreate = randomFeature(featureId) - featureToCreate.properties["test"] = "start" - val xyz = featureToCreate.properties.xyz - xyz.tags.clear() - xyz.tags.addTag("wicked", false) - - // Write the feature - val writeFeaturesReq = WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, featureToCreate).withAttachment(attachmentBytes)) - } - executeWrite(writeFeaturesReq).apply { - // Verify the result (will come from cache) - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals(Action.CREATED, feature.properties.xyz.guid?.tupleNumber?.action) - assertEquals("start", feature.properties["test"]) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - } - - // Read the feature - Naksha.cache.clear() - val readFeature: NakshaFeature - executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - featureIds += featureToCreate.id - }).apply { - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals(Action.CREATED, feature.properties.xyz.guid?.tupleNumber?.action) - assertEquals("start", feature.properties["test"]) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - - readFeature = feature - } - val insertedFeatureGuid = readFeature.properties.xyz.guid - assertNotNull(insertedFeatureGuid) - assertEquals(featureId, insertedFeatureGuid.id) - assertEquals(storage.number, insertedFeatureGuid.tupleNumber.storageNumber) - assertEquals(map.number, insertedFeatureGuid.tupleNumber.mapNumber) - assertEquals(collection.number, insertedFeatureGuid.tupleNumber.collectionNumber) - - // Now, update the feature, leave the attachment as it is. - // In other words, for this upsert, we do not provide an attachment, but expect to find it in the response! - Naksha.cache.clear() - val updateFeature: NakshaFeature = readFeature.copy(true) - updateFeature.properties["test"] = "end" - val updateFeatureReq = WriteRequest().apply { - // We do not modify attachment, therefore it should be UNDEFINED - add(Write().updateFeature(collection, updateFeature, true)) - } - executeWrite(updateFeatureReq).apply { - assertEquals(1, length) - assertEquals(1, features.size) - val feature = assertNotNull(features.first()) - assertEquals(featureToCreate.id, feature.id) - assertEquals("end", feature.properties["test"]) - assertEquals(Action.UPDATED, feature.properties.xyz.guid?.tupleNumber?.action) - - val featureTupleList = this.featureTupleList - assertEquals(1, featureTupleList.size) - val featureTuple = assertNotNull(featureTupleList[0]) - val tuple = assertNotNull(featureTuple.tuple) - assertNotNull(tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - assertContentEquals(attachmentBytes, tuple.getByteArray(naksha.model.objects.StandardMembers.Attachment)) - } - } -} diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ChainCollectionTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ChainCollectionTest.kt index 6f1f80940f..997e7547be 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ChainCollectionTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ChainCollectionTest.kt @@ -1,17 +1,18 @@ package naksha.psql import naksha.base.Int64 -import naksha.model.Naksha import naksha.model.objects.Index import naksha.model.objects.IndexType import naksha.model.objects.Member import naksha.model.objects.MemberType import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzTn import naksha.model.request.ReadFeatures import naksha.model.request.Write import naksha.model.request.WriteRequest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -64,6 +65,19 @@ class ChainCollectionTest : PgTestBase( else -> null } + @Test + fun allMembersShouldHaveAnEffectivePath() { + val members = assertNotNull(collection.members) + assertEquals(2, members.size) + assertEquals("left_fn", assertNotNull(members[0]).name) + assertContentEquals(listOf("properties", "left_fn"), assertNotNull(members[0]).path) + assertNull(assertNotNull(members[0]).path) + + assertEquals("right_fn", assertNotNull(members[1]).name) + assertContentEquals(listOf("properties", "right_fn"), assertNotNull(members[1]).path) + assertNull(assertNotNull(members[1]).path) + } + @Test fun shouldInsertAndReadChainFeatures() { // Given: three features forming a doubly-linked chain @@ -75,15 +89,15 @@ class ChainCollectionTest : PgTestBase( val tail = makeFeature(tailFn, leftFn = midFn, rightFn = null) executeWrite(WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, head)) - add(Write().createFeature(collection.mapId, collection.id, mid)) - add(Write().createFeature(collection.mapId, collection.id, tail)) + add(Write().createFeature(collection.catalogId, collection.id, head)) + add(Write().createFeature(collection.catalogId, collection.id, mid)) + add(Write().createFeature(collection.catalogId, collection.id, tail)) }) // When: reading all three back by their numeric IDs in one request val response = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += headFn.toString() featureIds += midFn.toString() featureIds += tailFn.toString() @@ -93,7 +107,7 @@ class ChainCollectionTest : PgTestBase( // Then: verify head val headBack = assertNotNull(response.features.find { it?.id == headFn.toString() }) - assertEquals(Int64(headFn), headBack.featureNumber) + assertEquals(Int64(headFn), XyzTn.get(headBack)?.featureNumber) assertNull( headBack.properties["left_fn"], "head.left_fn should be null" @@ -106,7 +120,7 @@ class ChainCollectionTest : PgTestBase( // Then: verify mid val midBack = assertNotNull(response.features.find { it?.id == midFn.toString() }) - assertEquals(Int64(midFn), midBack.featureNumber) + assertEquals(Int64(midFn), XyzTn.get(midBack)?.featureNumber) assertEquals( Int64(headFn), toInt64(midBack.properties["left_fn"]), @@ -120,7 +134,7 @@ class ChainCollectionTest : PgTestBase( // Then: verify tail val tailBack = assertNotNull(response.features.find { it?.id == tailFn.toString() }) - assertEquals(Int64(tailFn), tailBack.featureNumber) + assertEquals(Int64(tailFn), XyzTn.get(tailBack)?.featureNumber) assertEquals( Int64(midFn), toInt64(tailBack.properties["left_fn"]), @@ -138,7 +152,7 @@ class ChainCollectionTest : PgTestBase( val conn = storage.adminConnection() conn.use { // The head-table name pattern is "" inside the map schema. - val mapId = collection.mapId + val mapId = collection.catalogId val colId = collection.id // Query pg_indexes for our custom indices. val sql = """ @@ -167,15 +181,15 @@ class ChainCollectionTest : PgTestBase( val mid = makeFeature(midFn, leftFn = headFn, rightFn = tailFn) val tail = makeFeature(tailFn, leftFn = midFn, rightFn = null) executeWrite(WriteRequest().apply { - add(Write().upsertFeature(collection.mapId, collection.id, head)) - add(Write().upsertFeature(collection.mapId, collection.id, mid)) - add(Write().upsertFeature(collection.mapId, collection.id, tail)) + add(Write().upsertFeature(collection.catalogId, collection.id, head)) + add(Write().upsertFeature(collection.catalogId, collection.id, mid)) + add(Write().upsertFeature(collection.catalogId, collection.id, tail)) }) // When: reading all features from this collection (no ID filter) val all = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id }) // Then: find the feature whose right_fn == tailFn (that must be mid) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/CollectionTests.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/CollectionTests.kt index 2b848dc502..8038659a22 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/CollectionTests.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/CollectionTests.kt @@ -30,12 +30,14 @@ import naksha.model.objects.IndexType import naksha.model.objects.Member import naksha.model.objects.MemberList import naksha.model.objects.MemberType +import naksha.model.objects.XyzIndices +import naksha.model.objects.XyzMembers +import naksha.model.objects.XyzMembers.XyzMembers_C.XyzTn import naksha.model.request.ErrorResponse import naksha.model.request.ReadFeatures import naksha.model.request.Write import naksha.model.request.WriteRequest import kotlin.test.* -import naksha.psql.PgType class CollectionTests : PgTestBase(collection = null, mapId = "") { @@ -53,16 +55,16 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { // Then: this collection is queryable and empty val readAllFromCollection = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id } val collectionContent = executeRead(readAllFromCollection) assertEquals(0, collectionContent.features.size) // And: Virtual Collections contain the created collection val selectCollectionFromVirt = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += Naksha.COLLECTIONS_COL + catalogId = collection.catalogId + collectionId += Naksha.COLLECTIONS_COL_ID featureIds += collection.id } val virtBeforeDelete = executeRead(selectCollectionFromVirt) @@ -71,7 +73,7 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { // When: Collection gets deleted executeWrite( WriteRequest().add( - Write().deleteCollectionById(collection.mapId, collection.id) + Write().deleteCollectionById(collection.catalogId, collection.id) ) ) @@ -100,9 +102,9 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { args = arrayOf(collection.id) ).use { cursor -> while (cursor.next()) columns.add(cursor["column_name"]) - // HEAD has no `next_version` column (intrinsically HEAD); the table should match `headColumns`. - assertEquals(PgColumn.headColumns.size, columns.size) - assertTrue(PgColumn.headColumns.all { column -> columns.contains(column.name) }) + assertEquals(XyzMembers.ALL.size + 1, columns.size) + // Note: We will not find TN in the database, because `lib-psql` stores `fn` and `version` instead. + assertTrue(XyzMembers.ALL.all { column -> if (column eq XyzTn) true else columns.contains(column.name) }) } } } @@ -149,24 +151,12 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { sql = "SELECT indexname FROM pg_indexes WHERE tablename = $1;", args = arrayOf(tableName) ).use { cursor -> - val addedIndices = mutableListOf() - while (cursor.next()) addedIndices.add(cursor["indexname"]) - check(indices.size <= addedIndices.size) { "Too few indices" } - indices.forEach { indexName -> - check(indexName != null) - val pgIndex = PgIndex.of(indexName) - check(pgIndex != null) { "pgIndex of $indexName should not be null" } - // Note: We know that the `id` index is replaced with `id_unique` internally for HEAD tables! - if (pgIndex == PgIndex.id) { - check(addedIndices.contains(pgIndex.id(tableName)) - || addedIndices.contains(PgIndex.id_unique.id(tableName))) { - "Missing index ${pgIndex.name} aka $indexName" - } - } else { - check(addedIndices.contains(pgIndex.id(tableName))) { - "Missing index ${pgIndex.name} aka $indexName" - } - } + val existingIndices = mutableListOf() + while (cursor.next()) existingIndices.add(cursor["indexname"]) + check(indices.size <= existingIndices.size) { "Too few indices" } + XyzIndices.ALL.forEach { index -> + val existingName = existingIndices.find { index.name == it } + check(existingName != null) { "Index ${index.name} not found" } } } } @@ -208,8 +198,8 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { val readFeatureRequest = ReadFeatures() - readFeatureRequest.mapId = map.id - readFeatureRequest.collectionIds.add(collectionName) + readFeatureRequest.catalogId = map.id + readFeatureRequest.collectionId = collectionName readFeatureRequest.featureIds.add(feature.id) val readFeaturesResponse = executeRead(readFeatureRequest) assertEquals(1, readFeaturesResponse.features.size) @@ -281,8 +271,8 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { feature = featureCreateResponse.features[0]!! val readFeature = ReadFeatures() - readFeature.mapId = map.id - readFeature.collectionIds.add(collectionId) + readFeature.catalogId = map.id + readFeature.collectionId= collectionId readFeature.featureIds.add(feature.id) val readFeatureResponse = executeRead(readFeature) assertEquals(1, readFeatureResponse.features.size) @@ -331,8 +321,8 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { val responseCollection = assertNotNull(updateResponse.features[0]).proxy(NakshaCollection::class) assertEquals(StoreMode.SUSPEND, responseCollection.storeDeleted) val selectCollectionFromVirt = ReadFeatures().apply { - mapId = map.id - collectionIds += Naksha.COLLECTIONS_COL + catalogId = map.id + collectionId += Naksha.COLLECTIONS_COL_ID featureIds += collection.id } val colRead = assertNotNull(executeRead(selectCollectionFromVirt).features[0]).proxy(NakshaCollection::class) @@ -430,21 +420,22 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { @Test fun membersUndefined_shouldCreateAllColumnsAndDefaultIndices() { val collection = NakshaCollection("members_null_test", map.id) - // members is null by default — do NOT set it + // members are null by default — do NOT set them, then they will automatically become XyzMember.ALL! executeWrite(WriteRequest().add(Write().createCollection(collection))) storage.adminConnection().use { conn -> - // Columns: must include all headColumns (28 columns, no next_version). + // Columns: must include all headColumns (28 columns). val columns = mutableListOf() conn.execute( "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2", arrayOf(map.id, collection.id) ).use { cursor -> while (cursor.next()) columns.add(cursor["column_name"]) } + // Note: `lib-psql` does split `tn` into `fn` and `version` columns, so we have one more column than members! assertEquals( - PgColumn.headColumns.size, columns.size, - "Expected all ${PgColumn.headColumns.size} head columns, got: $columns" + XyzMembers.ALL.size + 1, columns.size, + "Expected to find ${XyzMembers.ALL+1} columns, found: ${columns.size}, being: $columns" ) - assertTrue(PgColumn.headColumns.all { it.name in columns }) + assertTrue(XyzMembers.ALL.all { XyzMembers.XyzTn eq it || it.name in columns }) // Indices: must include all default optional indices on the HEAD table. val indexNames = mutableListOf() @@ -452,11 +443,8 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { "SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND tablename = $2", arrayOf(map.id, collection.id) ).use { cursor -> while (cursor.next()) indexNames.add(cursor["indexname"]) } - val defaultIndices = PgIndex.DEFAULT_INDICES.filter { !it.internal } - for (pgIdx in defaultIndices) { - val expectedId = pgIdx.id(collection.id) - assertTrue(expectedId in indexNames || PgIndex.id_unique.id(collection.id) in indexNames, - "Expected default index '${pgIdx.name}' (id='$expectedId') to be present, found: $indexNames") + for (index in XyzIndices.ALL) { + assertTrue( index.name in indexNames,"Expected index '${index.name}' to be present, found: $indexNames") } } } @@ -525,7 +513,7 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { assertEquals(expectedCount, columns.size, "Expected ${PgColumn.headColumns.size} head columns + 1 custom column, got: $columns") assertTrue(PgColumn.headColumns.all { it.name in columns }) - val customColName = PgCustomMemberValues.pgColumnName("score") + val customColName = PgMemberHelper.pgColumnName("score") assertTrue(customColName in columns, "Custom column '$customColName' not found in: $columns") // Indices: no default optional indices; only the declared custom index must be present. @@ -548,14 +536,14 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { } /** - * When a custom [MemberType.SET] member is declared with a [IndexType.SET] index, the collection + * When a custom [MemberType.TAG_LIST] member is declared with a [IndexType.TAG_LIST] index, the collection * must materialize the member as a `jsonb` column and create a GIN index over it. */ @Test fun membersSet_shouldCreateJsonbColumnAndGinIndex() { val collection = NakshaCollection("members_set_test", map.id).apply { - addMember(Member("labels", MemberType.SET)) - addIndex(Index("idx_labels", IndexType.SET, "labels")) + addMember(Member("labels", MemberType.TAG_LIST)) + addIndex(Index("idx_labels", IndexType.TAG_LIST, "labels")) } executeWrite(WriteRequest().add(Write().createCollection(collection))) @@ -564,7 +552,7 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { var dataType: String? = null conn.execute( "SELECT data_type FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 AND column_name = $3", - arrayOf(map.id, collection.id, PgCustomMemberValues.pgColumnName("labels")) + arrayOf(map.id, collection.id, PgMemberHelper.pgColumnName("labels")) ).use { cursor -> if (cursor.next()) dataType = cursor["data_type"] } assertEquals("jsonb", dataType, "SET member 'labels' must be materialized as jsonb") @@ -582,13 +570,13 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { } /** - * A [IndexType.SET] index must be rejected when it targets a member that is not a [MemberType.SET]. + * A [IndexType.TAG_LIST] index must be rejected when it targets a member that is not a [MemberType.TAG_LIST]. */ @Test - fun membersSet_indexOnNonSetMemberShouldFail() { + fun membersSet_indexOnNonTagListMemberShouldFail() { val collection = NakshaCollection("members_set_invalid_test", map.id).apply { addMember(Member("score", MemberType.INT64)) - addIndex(Index("idx_set_score", IndexType.SET, "score")) + addIndex(Index("idx_tag_list_score", IndexType.TAG_LIST, "score")) } executeWriteErrorResponse(WriteRequest().add(Write().createCollection(collection))) } @@ -615,8 +603,8 @@ class CollectionTests : PgTestBase(collection = null, mapId = "") { addMember(Member("f_i8", MemberType.INT8)) addMember(Member("g_i16", MemberType.INT16)) addMember(Member("h_f32", MemberType.FLOAT32)) - addMember(Member("i_json", MemberType.TAGS)) - addMember(Member("j_set", MemberType.SET)) + addMember(Member("i_json", MemberType.TAG_MAP)) + addMember(Member("j_tag_list", MemberType.TAG_LIST)) } executeWrite(WriteRequest().add(Write().createCollection(collection))) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/DeleteFeatureBase.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/DeleteFeatureBase.kt index d1f157a599..c2f3c54e53 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/DeleteFeatureBase.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/DeleteFeatureBase.kt @@ -24,7 +24,7 @@ abstract class DeleteFeatureBase( val featureId = "feature_to_delete" val initialFeature = executeWrite( WriteRequest().add( - Write().createFeature(collection.mapId, collection.id, NakshaFeature(featureId)) + Write().createFeature(collection.catalogId, collection.id, NakshaFeature(featureId)) ) ).let { // this = SuccessResponse val features = assertNotNull(it.features) @@ -36,7 +36,7 @@ abstract class DeleteFeatureBase( val deletedFeatures = executeWrite( WriteRequest().add( - Write().deleteFeatureById(collection.mapId, collection.id, featureId) + Write().deleteFeatureById(collection.catalogId, collection.id, featureId) ) ).let { // this = SuccessResponse val features = assertNotNull(it.features) @@ -49,8 +49,8 @@ abstract class DeleteFeatureBase( // Verify that the feature does not exist Naksha.cache.clear() executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += initialFeature.id }).let { // this = SuccessResponse val features = assertNotNull(it.features) @@ -60,27 +60,28 @@ abstract class DeleteFeatureBase( // queryHistory=true (without queryDeleted) returns only past states from the history table. // The tombstone is in HEAD and is NOT included unless queryDeleted=true is also set. executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += initialFeature.id queryHistory = true versions = 10 }).apply { // this = SuccessResponse assertEquals(1, features.size) - assertSame(Action.CREATED, Action.fromValue((featureTupleList[0]?.tuple?.getLongMember(naksha.model.objects.StandardMembers.Version)!!.toInt() and 3) ?: -1)) + val firstTuple = featureTupleList[0]?.tuple + assertSame(Action.CREATE, Action.fromValue((firstTuple?.getLong(naksha.model.objects.StandardMembers.Version)?.toInt() ?: -1) and 3)) } // verify if delete table contains element executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += initialFeature.id queryDeleted = true }).apply { // this = SuccessResponse assertEquals(1, features.size) val deletedFeature = assertNotNull(features[0]) assertEquals(initialFeature.id, deletedFeature.id) - assertEquals(Action.DELETED, deletedFeature.properties.xyz.action) + assertEquals(Action.DELETE, deletedFeature.properties.xyz.action) } } @@ -95,7 +96,7 @@ abstract class DeleteFeatureBase( executeWrite(WriteRequest().add(Write().createFeature(collection, NakshaFeature(featureId)))) .features.first()!!.properties.xyz.guid?.tupleNumber ) - val createTxn = createdTn.version.txn + val createTxn = createdTn.version.value assertEquals(0L, (createTxn and ACTION_MASK).toLong(), "CREATE version must have action bits = 0 (CREATED)") @@ -104,7 +105,7 @@ abstract class DeleteFeatureBase( executeWrite(WriteRequest().add(Write().updateFeature(collection, NakshaFeature(featureId), false))) .features.first()!!.properties.xyz.guid?.tupleNumber ) - val updateTxn = updatedTn.version.txn + val updateTxn = updatedTn.version.value assertEquals(1L, (updateTxn and ACTION_MASK).toLong(), "UPDATE version must have action bits = 1 (UPDATED)") assertTrue((updateTxn and ACTION_CLEAR) > (createTxn and ACTION_CLEAR), @@ -115,7 +116,7 @@ abstract class DeleteFeatureBase( executeWrite(WriteRequest().add(Write().deleteFeatureById(collection, featureId))) .features.first()!!.properties.xyz.guid?.tupleNumber ) - val deleteTxn = deletedTn.version.txn + val deleteTxn = deletedTn.version.value // Lower 2 bits must be 2 (DELETED action). assertEquals(2L, (deleteTxn and ACTION_MASK).toLong(), @@ -135,16 +136,16 @@ abstract class DeleteFeatureBase( // Confirm the tombstone is visible via queryDeleted and has the right action. Naksha.cache.clear() executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId queryDeleted = true }).apply { assertEquals(1, features.size) val tombstone = assertNotNull(features[0]) - assertEquals(Action.DELETED, tombstone.properties.xyz.action) + assertEquals(Action.DELETE, tombstone.properties.xyz.action) // The raw version from HEAD must match exactly what the DELETE write returned. - assertEquals(deleteTxn, tombstone.properties.xyz.guid?.tupleNumber?.version?.txn) + assertEquals(deleteTxn, tombstone.properties.xyz.guid?.tupleNumber?.version?.value) } } @@ -163,7 +164,7 @@ abstract class DeleteFeatureBase( assertEquals(1, createCollectionResp.length) assertEquals(1, createCollectionResp.features.size) val collection = assertNotNull(createCollectionResp.features[0]).proxy(NakshaCollection::class) - assertEquals(map.id, collection.mapId) + assertEquals(map.id, collection.catalogId) assertEquals("delete_no_history_but_shadow", collection.id) // Create feature. @@ -186,4 +187,4 @@ abstract class DeleteFeatureBase( val deleteFeature = assertNotNull(deleteFeatureResp.features[0]) assertEquals(feature.id, deleteFeature.id) } -} \ No newline at end of file +} diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/HistoryUuidTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/HistoryUuidTest.kt index 8af9763c18..91a897a64e 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/HistoryUuidTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/HistoryUuidTest.kt @@ -11,7 +11,6 @@ import naksha.model.request.WriteRequest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull class HistoryUuidTest: PgTestBase(NakshaCollection( id = "history_puuid_test_collection", @@ -41,8 +40,8 @@ class HistoryUuidTest: PgTestBase(NakshaCollection( // And: Naksha.cache.clear() val featureVersions = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += feature.id queryHistory = true queryDeleted = true @@ -50,9 +49,9 @@ class HistoryUuidTest: PgTestBase(NakshaCollection( // Then: assertEquals(3, featureVersions.size) - val retrievedCreatedFeature = featureVersions.find { it.properties.xyz.action == Action.CREATED }!! - val retrievedUpdatedFeature = featureVersions.find { it.properties.xyz.action == Action.UPDATED }!! - val retrievedDeletedFeature = featureVersions.find { it.properties.xyz.action == Action.DELETED }!! + val retrievedCreatedFeature = featureVersions.find { it.properties.xyz.action == Action.CREATE }!! + val retrievedUpdatedFeature = featureVersions.find { it.properties.xyz.action == Action.UPDATE }!! + val retrievedDeletedFeature = featureVersions.find { it.properties.xyz.action == Action.DELETE }!! // And: assertNotNull(retrievedCreatedFeature.properties.xyz.uuid) @@ -88,8 +87,8 @@ class HistoryUuidTest: PgTestBase(NakshaCollection( // And: Naksha.cache.clear() val featureVersions = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += feature.id queryHistory = true queryDeleted = true @@ -97,9 +96,9 @@ class HistoryUuidTest: PgTestBase(NakshaCollection( // Then: assertEquals(3, featureVersions.size) - val retrievedCreatedFeature = featureVersions.find { it.properties.xyz.action == Action.CREATED }!! - val retrievedUpsertedFeature = featureVersions.find { it.properties.xyz.action == Action.UPDATED }!! - val retrievedDeletedFeature = featureVersions.find { it.properties.xyz.action == Action.DELETED }!! + val retrievedCreatedFeature = featureVersions.find { it.properties.xyz.action == Action.CREATE }!! + val retrievedUpsertedFeature = featureVersions.find { it.properties.xyz.action == Action.UPDATE }!! + val retrievedDeletedFeature = featureVersions.find { it.properties.xyz.action == Action.DELETE }!! // And: assertNotNull(retrievedCreatedFeature.properties.xyz.uuid) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/InsertFeatureTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/InsertFeatureTest.kt index e0db8d232b..093a135c37 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/InsertFeatureTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/InsertFeatureTest.kt @@ -22,7 +22,7 @@ class InsertFeatureTest : PgTestBase() { xyz.tags.clear() xyz.tags.addTag("wicked", false) val writeFeaturesReq = WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } // When: executing feature write request @@ -30,8 +30,8 @@ class InsertFeatureTest : PgTestBase() { // And: reading all features from collection val readResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureToCreate.id }) val retrievedFeatures = readResponse.features @@ -54,7 +54,7 @@ class InsertFeatureTest : PgTestBase() { retrievedXyz .hasProperty("appId", PgTest.TEST_APP_ID) .hasProperty("author", PgTest.TEST_APP_AUTHOR) - .hasProperty("action", Action.CREATED.text) + .hasProperty("action", Action.CREATE.text) } .hasTags(TagList("wicked")) } @@ -96,7 +96,7 @@ class InsertFeatureTest : PgTestBase() { val xyz = featureToCreate.properties.xyz xyz.tags.addTag("wicked", false) val writeFeaturesReq = WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } // When: executing feature write request @@ -104,8 +104,8 @@ class InsertFeatureTest : PgTestBase() { // And: reading all features from collection val readResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureToCreate.id }) val retrievedFeatures = readResponse.features @@ -129,7 +129,7 @@ class InsertFeatureTest : PgTestBase() { retrievedXyz .hasProperty("appId", PgTest.TEST_APP_ID) .hasProperty("author", PgTest.TEST_APP_AUTHOR) - .hasProperty("action", Action.CREATED.text) + .hasProperty("action", Action.CREATE.text) } .hasTags(TagList("wicked")) } @@ -140,7 +140,7 @@ class InsertFeatureTest : PgTestBase() { // Given: features to create val featureToCreate = randomFeature() val writeFeaturesReq = WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } // When: executing feature write request @@ -148,8 +148,8 @@ class InsertFeatureTest : PgTestBase() { // And: reading all features from collection val readResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureToCreate.id }) val retrievedFeatures = readResponse.features @@ -169,7 +169,7 @@ class InsertFeatureTest : PgTestBase() { val featuresToCreate = randomFeatures(count = count) val writeFeaturesReq = WriteRequest().apply { featuresToCreate.forEach { featureToCreate -> - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } } val firstFeatureToCreate = featuresToCreate[0] @@ -193,8 +193,8 @@ class InsertFeatureTest : PgTestBase() { // And: reading all features from collection val readResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id // this.version = version // this.minVersion = version }) @@ -229,7 +229,7 @@ class InsertFeatureTest : PgTestBase() { retrievedXyz .hasProperty("appId", PgTest.TEST_APP_ID) .hasProperty("author", PgTest.TEST_APP_AUTHOR) - .hasProperty("action", Action.CREATED.text) + .hasProperty("action", Action.CREATE.text) } } } @@ -238,8 +238,8 @@ class InsertFeatureTest : PgTestBase() { Platform.logger.info("Clear cache and reload feature from database") Naksha.cache.clear(storage) val featuresByIdResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds.add(firstFeatureToCreate.id) }) @@ -258,8 +258,8 @@ class InsertFeatureTest : PgTestBase() { // Read only one feature by bounding box. val featuresByBBox = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id query.spatial = SpIntersects(SpBoundingBox(firstFeatureToCreate.geometry).addMargin(0.0000001).toPolygon()) }) @@ -277,15 +277,15 @@ class InsertFeatureTest : PgTestBase() { val namedFeature = randomFeature() // has a UUID-style named id (fn < 0) val writeReq = WriteRequest().apply { - add(Write().createFeature(collection.mapId, collection.id, numericFeature)) - add(Write().createFeature(collection.mapId, collection.id, namedFeature)) + add(Write().createFeature(collection.catalogId, collection.id, numericFeature)) + add(Write().createFeature(collection.catalogId, collection.id, namedFeature)) } executeWrite(writeReq) // When: reading both features in a single request with mixed IDs val readResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += numericId featureIds += namedFeature.id }) @@ -308,7 +308,7 @@ class InsertFeatureTest : PgTestBase() { // And val writeReq = WriteRequest().add( - Write().createFeature(collection.mapId, collection.id, featureWithDuplicatedId) + Write().createFeature(collection.catalogId, collection.id, featureWithDuplicatedId) ) val insertDuplicateResponse = newWriteSession().use { session -> session.execute(writeReq) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PartitioningTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PartitioningTest.kt index 2ac9f97a70..8aa54cb7d5 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PartitioningTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PartitioningTest.kt @@ -5,7 +5,6 @@ import naksha.model.SessionOptions import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.request.* -import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals @@ -90,8 +89,8 @@ class PartitioningTest : PgTestBase() { // also - should be able to read val readRequest = ReadFeatures() - readRequest.mapId = partitionedCollection.mapId - readRequest.collectionIds.add(partitionedCollection.id) + readRequest.catalogId = partitionedCollection.catalogId + readRequest.collectionId.add(partitionedCollection.id) readRequest.featureIds.add("f1") val readResponse = executeRead(readRequest) assertEquals(1, readResponse.features.size) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgPropertyFilterTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgPropertyFilterTest.kt index 94305043ef..be8dac0579 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgPropertyFilterTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgPropertyFilterTest.kt @@ -33,8 +33,8 @@ class PgPropertyFilterTest: PgTestBase() { // And: A read request is created with the property query. val readRequest = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id }.withPropertyQuery(pQuery) // When: read request is executed val response = executeRead(readRequest) @@ -65,8 +65,8 @@ class PgPropertyFilterTest: PgTestBase() { // And: A read request is made with the custom filter manually added. val readRequest = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id resultFilters.add(IdContainsFilter("keep_this")) } // When: read request is executed @@ -123,8 +123,8 @@ class PgPropertyFilterTest: PgTestBase() { fun shouldNotTriggerFilteringForRequestWithErrorResponse() { // Given: A read request that is designed to fail by targeting a non-existent collection. val readRequest = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += "non_existent_collection" + catalogId = collection.catalogId + collectionId += "non_existent_collection" } // When: The failing request is executed. @@ -145,8 +145,8 @@ class PgPropertyFilterTest: PgTestBase() { // When: A read request is made to fetch that specific feature by ID, with no result filters. val readRequest = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += feature.id } val response = storage.newReadSession(newSessionOptions()).use { session -> diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgRelationTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgRelationTest.kt index ed34c26ded..685d2a0ba2 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgRelationTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgRelationTest.kt @@ -19,10 +19,10 @@ class PgRelationTest { ) } // expect - assertEquals(1, pgRelation("topology\$p001").partitionNumber()) - assertEquals(0, pgRelation("topology\$p000").partitionNumber()) - assertEquals(256, pgRelation("topology\$del\$p256").partitionNumber()) - assertEquals(7, pgRelation("topology\$hst\$2024\$p007").partitionNumber()) + assertEquals(1, pgRelation("topology\$p001").distributionPartition()) + assertEquals(0, pgRelation("topology\$p000").distributionPartition()) + assertEquals(256, pgRelation("topology\$del\$p256").distributionPartition()) + assertEquals(7, pgRelation("topology\$hst\$2024\$p007").distributionPartition()) assertEquals(2024, pgRelation("topology\$hst\$2024\$p001").year()) assertEquals(2024, pgRelation("topology\$hst\$2024").year()) } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgTestBase.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgTestBase.kt index 113acbf5be..03431be2ea 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgTestBase.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgTestBase.kt @@ -11,7 +11,7 @@ import naksha.common.test.CommonTestConstants import naksha.model.* import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature -import naksha.model.objects.NakshaMap +import naksha.model.objects.NakshaCatalog import naksha.model.objects.NakshaStorage import naksha.model.request.* import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID @@ -36,12 +36,12 @@ abstract class PgTestBase( collection: NakshaCollection? = NakshaCollection(""), mapId: String? = null, ) { - private var _map: NakshaMap? = null + private var _map: NakshaCatalog? = null /** * The selected map, throws an exception, when read before initialized _(the constructor by default initialized this)_. */ - val map: NakshaMap + val map: NakshaCatalog get() = assertNotNull(_map, "Illegal state, no map used by the test") private var _collection: NakshaCollection? = null @@ -90,7 +90,7 @@ abstract class PgTestBase( * @param mapId the `id` of the map to use within this test. If `null`, then [PgTest.TEST_MAP_ID] is used, if being an empty string _(`""`)_, then the camel-cased name of the test-class being used. * @return the map object. */ - fun initMap(mapId: String?): NakshaMap { + fun initMap(mapId: String?): NakshaCatalog { val map_id = ensureMapId(mapId) var mapRef = initializedMaps[map_id] if (mapRef == null) { @@ -103,14 +103,14 @@ abstract class PgTestBase( dropMap(map_id) // Create the map. - var map = NakshaMap(map_id) + var map = NakshaCatalog(map_id) val request = WriteRequest() request.writes += Write().createMap(map) val response = executeWrite(request) val features = response.features assertEquals(1, features.size) val feature = features.first() - map = assertNotNull(feature).proxy(NakshaMap::class) + map = assertNotNull(feature).proxy(NakshaCatalog::class) mapRef = MapAndCollections(map) initializedMaps[map_id] = assertNotNull(mapRef) logger.info("Created test map: '$map_id'") @@ -125,27 +125,27 @@ abstract class PgTestBase( * @param mapId the `id` of the map to initialize and use. * @return the map object. */ - fun useMap(mapId: String?): NakshaMap { + fun useMap(mapId: String?): NakshaCatalog { val map = initMap(mapId) this._map = map return map } - fun initCollection(mapId: String, collectionId: String): Pair + fun initCollection(mapId: String, collectionId: String): Pair = initCollection(NakshaCollection(collectionId, mapId)) - fun initCollection(collection: NakshaCollection): Pair { + fun initCollection(collection: NakshaCollection): Pair { if (collection.id == "") { collection.id = defaultName } else { Naksha.verifyId(collection.id) } - if (collection.mapId == null || collection.mapId == "") { - collection.mapId = map.id + if (collection.catalogId == null || collection.catalogId == "") { + collection.catalogId = map.id } else { - Naksha.verifyId(collection.mapId) + Naksha.verifyId(collection.catalogId) } - val mapId = collection.mapId + val mapId = collection.catalogId initMap(mapId) val mapRef = assertNotNull(initializedMaps[mapId], "The map '$mapId' failed to initialized") val colId = collection.id @@ -154,7 +154,7 @@ abstract class PgTestBase( lock.acquire().use { existing = mapRef.collections[colId] if (existing == null) { - collection.mapId = mapId + collection.catalogId = mapId val request = WriteRequest() request.writes += Write().createCollection(collection) storage.newWriteSession(newSessionOptions()).use { session -> @@ -173,10 +173,10 @@ abstract class PgTestBase( return Pair(mapRef.map, assertNotNull(existing)) } - fun useCollection(collectionId: String, mapId: String = map.id): Pair + fun useCollection(collectionId: String, mapId: String = map.id): Pair = useCollection(NakshaCollection(collectionId, mapId)) - fun useCollection(collection: NakshaCollection): Pair { + fun useCollection(collection: NakshaCollection): Pair { val pair = initCollection(collection) this._map = pair.first this._collection = pair.second @@ -297,7 +297,7 @@ abstract class PgTestBase( /** * The map. */ - val map: NakshaMap, + val map: NakshaCatalog, /** * All collections in the map, created for the tests by `id`. diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgUtilTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgUtilTest.kt index e727e93050..7029304570 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgUtilTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/PgUtilTest.kt @@ -11,68 +11,68 @@ import kotlin.test.assertNotNull class PgUtilTest { - @Test - fun shouldDecodeEncodedFeature() { - // Given - val beforeEncoding = NakshaFeature().apply { - id = "feature_1" - properties = NakshaProperties().apply { - setRaw("featureType", "some_feature_type") - xyz = make( - "appId" to "someAppId", - "author" to "someAuthor" - ) - } - } +// @Test +// fun shouldDecodeEncodedFeature() { +// // Given +// val beforeEncoding = NakshaFeature().apply { +// id = "feature_1" +// properties = NakshaProperties().apply { +// setRaw("featureType", "some_feature_type") +// xyz = make( +// "appId" to "someAppId", +// "author" to "someAuthor" +// ) +// } +// } +// +// // When: +// val encoding = DataEncoding.JBON +// val encoded = PgUtil.encodeFeature(beforeEncoding, encoding) +// +// // And: +// val decoded = PgUtil.decodeFeature(encoded) +// +// // Then: features are equal but decoded one is missing Xyz +// // note: Xyz should be populated after decoding (it's not stored in `feature` column, it's scattered in other columns) +// assertNotNull(decoded) +// assertThatFeature(beforeEncoding) +// .isIdenticalTo(decoded, ignoreProps = true) +// .hasPropertiesThat { decodedProperties -> +// decodedProperties +// .hasFeatureType(beforeEncoding.properties.featureType) +// .hasXyzThat { it.isEmpty() } +// } +// } - // When: - val encoding = DataEncoding.JBON - val encoded = PgUtil.encodeFeature(beforeEncoding, encoding) - - // And: - val decoded = PgUtil.decodeFeature(encoded) - - // Then: features are equal but decoded one is missing Xyz - // note: Xyz should be populated after decoding (it's not stored in `feature` column, it's scattered in other columns) - assertNotNull(decoded) - assertThatFeature(beforeEncoding) - .isIdenticalTo(decoded, ignoreProps = true) - .hasPropertiesThat { decodedProperties -> - decodedProperties - .hasFeatureType(beforeEncoding.properties.featureType) - .hasXyzThat { it.isEmpty() } - } - } - - @Test - fun shouldDecodeJbon2EncodedFeature() { - for (encoding in listOf(DataEncoding.JBON2, DataEncoding.JBON2_GZIP)) { - // Given - val beforeEncoding = NakshaFeature().apply { - id = "feature_1" - properties = NakshaProperties().apply { - setRaw("featureType", "some_feature_type") - setRaw("name", "hello world") - xyz = make( - "appId" to "someAppId", - "author" to "someAuthor" - ) - } - } - - // When: - val encoded = PgUtil.encodeFeature(beforeEncoding, encoding) - val decoded = PgUtil.decodeFeature(encoded) - - // Then: same parity contract as JBON1 (Xyz lives in separate columns, not the feature blob). - assertNotNull(decoded, "decoded should not be null for $encoding") - assertThatFeature(beforeEncoding) - .isIdenticalTo(decoded, ignoreProps = true) - .hasPropertiesThat { decodedProperties -> - decodedProperties - .hasFeatureType(beforeEncoding.properties.featureType) - .hasXyzThat { it.isEmpty() } - } - } - } +// @Test +// fun shouldDecodeJbon2EncodedFeature() { +// for (encoding in listOf(DataEncoding.JBON2, DataEncoding.JBON2_GZIP)) { +// // Given +// val beforeEncoding = NakshaFeature().apply { +// id = "feature_1" +// properties = NakshaProperties().apply { +// setRaw("featureType", "some_feature_type") +// setRaw("name", "hello world") +// xyz = make( +// "appId" to "someAppId", +// "author" to "someAuthor" +// ) +// } +// } +// +// // When: +// val encoded = PgUtil.encodeFeature(beforeEncoding, encoding) +// val decoded = PgUtil.decodeFeature(encoded) +// +// // Then: same parity contract as JBON1 (Xyz lives in separate columns, not the feature blob). +// assertNotNull(decoded, "decoded should not be null for $encoding") +// assertThatFeature(beforeEncoding) +// .isIdenticalTo(decoded, ignoreProps = true) +// .hasPropertiesThat { decodedProperties -> +// decodedProperties +// .hasFeatureType(beforeEncoding.properties.featureType) +// .hasXyzThat { it.isEmpty() } +// } +// } +// } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesAll.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesAll.kt index 40421a1051..0f45474e51 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesAll.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesAll.kt @@ -1,11 +1,9 @@ package naksha.psql import naksha.model.Action -import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.request.* import naksha.model.RandomFeatures.RandomFeatures_C.randomFeatures -import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID import kotlin.test.* class ReadFeaturesAll : PgTestBase() { @@ -21,7 +19,7 @@ class ReadFeaturesAll : PgTestBase() { val featuresToCreate = randomFeatures(COUNT) val writeFeaturesReq = WriteRequest().apply { featuresToCreate.forEach { featureToCreate -> - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } } val writeFeaturesResp = executeWrite(writeFeaturesReq) @@ -29,7 +27,7 @@ class ReadFeaturesAll : PgTestBase() { for (feature in writeFeaturesResp.features) { assertNotNull(feature) assertNull(allFeatures[feature.id]) - assertEquals(Action.CREATED, feature.properties.xyz.action) + assertEquals(Action.CREATE, feature.properties.xyz.action) allFeatures[feature.id] = feature } } @@ -37,8 +35,8 @@ class ReadFeaturesAll : PgTestBase() { @Test fun shouldReturnAllFeatures() { executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id }).apply { assertEquals(COUNT, features.size) for (feature in features) { diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGeometryTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGeometryTest.kt index 8e1ed50f2d..aa128e9bd1 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGeometryTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGeometryTest.kt @@ -33,8 +33,8 @@ class ReadFeaturesByGeometryTest : PgTestBase(collection = null, mapId = "") { // And: reading feature val retrievedFeatures = executeRead( ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += feature.id } ).features @@ -66,8 +66,8 @@ class ReadFeaturesByGeometryTest : PgTestBase(collection = null, mapId = "") { // And: reading feature val retrievedFeatures = executeRead( ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += feature.id } ).features @@ -251,8 +251,8 @@ class ReadFeaturesByGeometryTest : PgTestBase(collection = null, mapId = "") { private fun executeSpatialQuery(spatialQuery: ISpatialQuery): SuccessResponse { return executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id query.spatial = spatialQuery }) } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGuuidTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGuuidTest.kt index 584383d57f..08444df283 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGuuidTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByGuuidTest.kt @@ -25,8 +25,8 @@ class ReadFeaturesByGuuidTest : // When val readByGuid = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id guids = GuidList().apply { add(guuidById[inputFeature1.id]) add(guuidById[inputFeature3.id]) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByMetadataTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByMetadataTest.kt index c0fbf7fb88..4512363a80 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByMetadataTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByMetadataTest.kt @@ -31,8 +31,8 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByAppId = executeMetaQuery( - MetaQuery( - column = MetaColumn.appId(), + MemberQuery( + member = MetaColumn.appId(), op = StringOp.EQUALS, value = sessionOptions.appId ) @@ -58,8 +58,8 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByAppIdPrefix = executeMetaQuery( - MetaQuery( - column = MetaColumn.appId(), + MemberQuery( + member = MetaColumn.appId(), op = StringOp.STARTS_WITH, value = "prefixed_test_app" ) @@ -85,8 +85,8 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByAuthor = executeMetaQuery( - MetaQuery( - column = MetaColumn.author(), + MemberQuery( + member = MetaColumn.author(), op = StringOp.EQUALS, value = sessionOptions.author ) @@ -112,8 +112,8 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByAuthorPrefix = executeMetaQuery( - MetaQuery( - column = MetaColumn.author(), + MemberQuery( + member = MetaColumn.author(), op = StringOp.STARTS_WITH, value = "Jacky" ) @@ -136,8 +136,8 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresById = executeMetaQuery( - MetaQuery( - column = MetaColumn.id(), + MemberQuery( + member = MetaColumn.id(), op = StringOp.EQUALS, value = inputFeature.id ) @@ -160,8 +160,8 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByIdPrefix = executeMetaQuery( - MetaQuery( - column = MetaColumn.id(), + MemberQuery( + member = MetaColumn.id(), op = StringOp.STARTS_WITH, value = TEST_FEATURE_ID.substring(0..4) ) @@ -186,7 +186,7 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByType = executeMetaQuery( - MetaQuery(MetaColumn.featureType(), StringOp.EQUALS, inputFeature.featureType) + MemberQuery(MetaColumn.featureType(), StringOp.EQUALS, inputFeature.featureType) ).features // Then: @@ -208,7 +208,7 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresByTypePrefix = executeMetaQuery( - MetaQuery(MetaColumn.featureType(), StringOp.STARTS_WITH, "quite") + MemberQuery(MetaColumn.featureType(), StringOp.STARTS_WITH, "quite") ).features // Then: @@ -233,11 +233,11 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: execute val featuresByAppIdAndAuthor = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - query.metadata = MetaAnd( - MetaQuery(MetaColumn.author(), StringOp.EQUALS, author), - MetaQuery(MetaColumn.appId(), StringOp.STARTS_WITH, appId.substring(0, 2)) + catalogId = collection.catalogId + collectionId += collection.id + query.members = MemberAnd( + MemberQuery(MetaColumn.author(), StringOp.EQUALS, author), + MemberQuery(MetaColumn.appId(), StringOp.STARTS_WITH, appId.substring(0, 2)) ) }) @@ -257,7 +257,7 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { val insertedFeatureXyz = insertFeatureAndGetXyz(inputFeature) // And: - val featuresByCreatedAt = executeMetaQuery(MetaQuery( + val featuresByCreatedAt = executeMetaQuery(MemberQuery( MetaColumn.createdAt(), DoubleOp.EQ, insertedFeatureXyz.createdAt @@ -279,7 +279,7 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { val insertedFeatureXyz = insertFeatureAndGetXyz(inputFeature) // And: - val featuresByUpdatedAt = executeMetaQuery(MetaQuery( + val featuresByUpdatedAt = executeMetaQuery(MemberQuery( MetaColumn.updatedAt(), DoubleOp.EQ, insertedFeatureXyz.updatedAt @@ -304,13 +304,13 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresCreatedInFrame = executeMetaQuery( - MetaAnd( - MetaQuery( + MemberAnd( + MemberQuery( MetaColumn.createdAt(), DoubleOp.GT, insertedFeatureXyz.createdAt - 100 ), - MetaQuery( + MemberQuery( MetaColumn.createdAt(), DoubleOp.LT, insertedFeatureXyz.createdAt + 100 @@ -337,13 +337,13 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: val featuresUpdatedInFrame = executeMetaQuery( - MetaAnd( - MetaQuery( + MemberAnd( + MemberQuery( MetaColumn.updatedAt(), DoubleOp.GTE, insertedFeatureXyz.updatedAt ), - MetaQuery( + MemberQuery( MetaColumn.updatedAt(), DoubleOp.LTE, insertedFeatureXyz.updatedAt + 100 @@ -367,7 +367,7 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { val insertedFeatureXyz = insertFeatureAndGetXyz(inputFeature) // And: - val featuresByAuthorTs = executeMetaQuery(MetaQuery( + val featuresByAuthorTs = executeMetaQuery(MemberQuery( MetaColumn.authorTs(), DoubleOp.EQ, insertedFeatureXyz.authorTs @@ -391,11 +391,11 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: execute val featuresByAppIdAndAuthor = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - query.metadata = MetaOr( - MetaQuery(MetaColumn.author(), StringOp.EQUALS, "this_is_totally_off"), - MetaQuery(MetaColumn.appId(), StringOp.STARTS_WITH, appId.substring(0, 2)) + catalogId = collection.catalogId + collectionId += collection.id + query.members = MemberOr( + MemberQuery(MetaColumn.author(), StringOp.EQUALS, "this_is_totally_off"), + MemberQuery(MetaColumn.appId(), StringOp.STARTS_WITH, appId.substring(0, 2)) ) }).features @@ -421,12 +421,12 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // And: History table is queried for everything besides CREATED val getHistoryWithoutUpdates = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id queryHistory = true queryDeleted = true query = RequestQuery().apply { - metadata = MetaQuery(MetaColumn.action(), DoubleOp.NE, Action.CREATED.intValue) + members = MemberQuery(MetaColumn.action(), DoubleOp.NE, Action.CREATE.intValue) } } val response = executeRead(getHistoryWithoutUpdates) @@ -435,14 +435,14 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { // Then: We only got DELETED state assertEquals(1, retrievedFeatures.size) val singleRetrievedHistoryFeature = retrievedFeatures[0]!! - assertEquals(Action.DELETED, singleRetrievedHistoryFeature.properties.xyz.action) + assertEquals(Action.DELETE, singleRetrievedHistoryFeature.properties.xyz.action) } private fun insertFeatureAndGetXyz(feature: NakshaFeature): XyzNs { insertFeature(feature = feature) val persistedFeatureResponse = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += feature.id }) val persistedFeatures = persistedFeatureResponse.features @@ -452,11 +452,11 @@ class ReadFeaturesByMetadataTest : PgTestBase(collection = null, mapId = "") { return persistedFeature.properties.xyz } - private fun executeMetaQuery(metaQuery: IMetaQuery): SuccessResponse { + private fun executeMetaQuery(metaQuery: IMemberQuery): SuccessResponse { return executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - query.metadata = metaQuery + catalogId = collection.catalogId + collectionId += collection.id + query.members = metaQuery }) } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByOtherTns.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByOtherTns.kt index 12ce04631b..efd1f0338f 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByOtherTns.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByOtherTns.kt @@ -8,7 +8,7 @@ import naksha.model.request.Write import naksha.model.request.WriteRequest import naksha.model.request.query.AnyOp import naksha.model.request.query.MetaColumn -import naksha.model.request.query.MetaQuery +import naksha.model.request.query.MemberQuery import naksha.psql.assertions.NakshaFeatureFluidAssertions.Companion.assertThatFeature import kotlin.test.Test import kotlin.test.assertEquals @@ -40,18 +40,18 @@ class ReadFeaturesByOtherTns : PgTestBase( val updateResp = executeWrite(update) // And: the shared `next_version` of all updated features (all 5 updates ran in one transaction). - val updatedVersion: Int64 = updateResp.features[0]!!.tupleNumber.version.txn + val updatedVersion: Int64 = updateResp.features[0]!!.tupleNumber.version.value // When: querying for features whose `next_version` matches that version - val nextVersionQuery = MetaQuery( + val nextVersionQuery = MemberQuery( MetaColumn.nextVersion(), AnyOp.IS_ANY_OF, arrayOf(updatedVersion) ) val byNextTnResp = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id - query.metadata = nextVersionQuery + catalogId = collection.catalogId + collectionId += collection.id + query.members = nextVersionQuery queryHistory = true }) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByRefTilesTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByRefTilesTest.kt index bb380d3443..4c3b74f8d5 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByRefTilesTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByRefTilesTest.kt @@ -46,8 +46,8 @@ class ReadFeaturesByRefTilesTest : PgTestBase(collection = null, mapId = "") { // Given: val getFeaturesFromZagrebAndPrague = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection!!.id + catalogId = collection.catalogId + collectionId += collection!!.id query.refTiles += listOf(zagrebTileLv12.intKey, pragueTileLv12.intKey) } @@ -67,8 +67,8 @@ class ReadFeaturesByRefTilesTest : PgTestBase(collection = null, mapId = "") { // Given: val getFeaturesFromBologna = ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id query.refTiles += bolognaTileLv12.intKey } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt index 044f5396e0..2219855e6e 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt @@ -1,13 +1,11 @@ package naksha.psql import naksha.model.TagList -import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.request.ReadFeatures import naksha.model.request.SuccessResponse import naksha.model.request.query.* import naksha.model.RandomFeatures -import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -50,7 +48,7 @@ class ReadFeaturesByTagsTest : PgTestBase() { } /** - * The default tags member is a [naksha.model.objects.MemberType.SET]: the tags array is stored + * The default tags member is a [naksha.model.objects.MemberType.TAG_LIST]: the tags list is stored * unmodified, the values are never split into key/value pairs. Therefore only full elements can * be matched — `TagExists("fullelem")` does not find a feature tagged `["fullelem=bar"]`. */ @@ -225,8 +223,8 @@ class ReadFeaturesByTagsTest : PgTestBase() { private fun executeTagsQuery(tagQuery: ITagQuery): SuccessResponse { return executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection!!.id + catalogId = collection.catalogId + collectionId += collection!!.id query.tags = tagQuery }) } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadHistoryTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadHistoryTest.kt index 8983f28823..e9c93382ec 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadHistoryTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadHistoryTest.kt @@ -23,7 +23,7 @@ class ReadHistoryTest : PgTestBase() { val featuresToCreate = randomFeatures(COUNT) val writeFeaturesReq = WriteRequest().apply { featuresToCreate.forEach { featureToCreate -> - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } } val writeFeaturesResp = executeWrite(writeFeaturesReq) @@ -31,7 +31,7 @@ class ReadHistoryTest : PgTestBase() { for (feature in writeFeaturesResp.features) { assertNotNull(feature) assertNull(allFeatures[feature.id]) - assertEquals(Action.CREATED, feature.properties.xyz.action) + assertEquals(Action.CREATE, feature.properties.xyz.action) allFeatures[feature.id] = feature } } @@ -40,7 +40,7 @@ class ReadHistoryTest : PgTestBase() { fun checkSingleFeatureHistory() { // Pick one feature val createdFeature = allFeatures.firstNotNullOf { it.value } - assertEquals(Action.CREATED, createdFeature.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.CREATE, createdFeature.properties.xyz.guid?.tupleNumber?.action) val featureId = createdFeature.id // Update it. @@ -52,7 +52,7 @@ class ReadHistoryTest : PgTestBase() { assertEquals(1, features.size) updatedFeature1 = assertNotNull(features.first()) assertEquals(featureId, updatedFeature1.id) - assertEquals(Action.UPDATED, updatedFeature1.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.UPDATE, updatedFeature1.properties.xyz.guid?.tupleNumber?.action) } // Update it a second time. @@ -64,7 +64,7 @@ class ReadHistoryTest : PgTestBase() { assertEquals(1, features.size) updatedFeature2 = assertNotNull(features.first()) assertEquals(featureId, updatedFeature2.id) - assertEquals(Action.UPDATED, updatedFeature2.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.UPDATE, updatedFeature2.properties.xyz.guid?.tupleNumber?.action) } // Delete it. @@ -75,14 +75,14 @@ class ReadHistoryTest : PgTestBase() { assertEquals(1, features.size) deletedFeature = assertNotNull(features.first()) assertEquals(featureId, deletedFeature.id) - assertEquals(Action.DELETED, deletedFeature.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.DELETE, deletedFeature.properties.xyz.guid?.tupleNumber?.action) } // Clear cache, and read the history of the feature. Naksha.cache.clear() executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds.add(collection.id) + catalogId = collection.catalogId + collectionId.add(collection.id) featureIds.add(featureId) queryHistory = true queryDeleted = true @@ -97,32 +97,32 @@ class ReadHistoryTest : PgTestBase() { val create = assertNotNull(features[3]) assertEquals(featureId, delete.id) - assertEquals(Action.DELETED, delete.properties.xyz.action) - assertEquals(Action.DELETED, delete.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.DELETE, delete.properties.xyz.action) + assertEquals(Action.DELETE, delete.properties.xyz.guid?.tupleNumber?.action) assertEquals(delete.properties.xyz.nguid, delete.properties.xyz.guid) assertEquals(featureId, update2.id) - assertEquals(Action.UPDATED, update2.properties.xyz.action) - assertEquals(Action.UPDATED, update2.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.UPDATE, update2.properties.xyz.action) + assertEquals(Action.UPDATE, update2.properties.xyz.guid?.tupleNumber?.action) assertEquals("second_update", update2.properties[ALIAS]) assertEquals(update2.properties.xyz.nguid, delete.properties.xyz.guid) assertEquals(featureId, update1.id) - assertEquals(Action.UPDATED, update1.properties.xyz.action) - assertEquals(Action.UPDATED, update1.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.UPDATE, update1.properties.xyz.action) + assertEquals(Action.UPDATE, update1.properties.xyz.guid?.tupleNumber?.action) assertEquals("first_update", update1.properties[ALIAS]) assertEquals(update1.properties.xyz.nguid, update2.properties.xyz.guid) assertEquals(featureId, create.id) - assertEquals(Action.CREATED, create.properties.xyz.action) - assertEquals(Action.CREATED, create.properties.xyz.guid?.tupleNumber?.action) + assertEquals(Action.CREATE, create.properties.xyz.action) + assertEquals(Action.CREATE, create.properties.xyz.guid?.tupleNumber?.action) assertNull(create.properties[ALIAS]) assertEquals(create.properties.xyz.nguid, update1.properties.xyz.guid) } executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds.add(collection.id) + catalogId = collection.catalogId + collectionId.add(collection.id) featureIds.add(featureId) queryHistory = true queryDeleted = true @@ -136,15 +136,15 @@ class ReadHistoryTest : PgTestBase() { val update2 = assertNotNull(features[1]) assertEquals(featureId, delete.id) - assertEquals(Action.DELETED, delete.properties.xyz.action) + assertEquals(Action.DELETE, delete.properties.xyz.action) assertEquals(delete.properties.xyz.nguid, delete.properties.xyz.guid) - assertEquals(Action.UPDATED, update2.properties.xyz.action) + assertEquals(Action.UPDATE, update2.properties.xyz.action) } executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds.add(collection.id) + catalogId = collection.catalogId + collectionId.add(collection.id) featureIds.add(featureId) queryHistory = true version = updatedFeature2.guid!!.tupleNumber.version @@ -158,10 +158,10 @@ class ReadHistoryTest : PgTestBase() { val update1 = assertNotNull(features[1]) assertEquals(featureId, update1.id) - assertEquals(Action.UPDATED, update1.properties.xyz.action) + assertEquals(Action.UPDATE, update1.properties.xyz.action) assertEquals(featureId, update2.id) - assertEquals(Action.UPDATED, update2.properties.xyz.action) + assertEquals(Action.UPDATE, update2.properties.xyz.action) assertEquals(update2.guid, update1.properties.xyz.nguid) } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadLimitTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadLimitTest.kt index 4f7f290270..51f0460a69 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadLimitTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadLimitTest.kt @@ -21,8 +21,8 @@ class ReadLimitTest : PgTestBase() { // When val readWithLimit = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id limit = 2 }) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadOrderedTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadOrderedTest.kt index 0a8ac66b0d..8e34ffbd6a 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadOrderedTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadOrderedTest.kt @@ -1,7 +1,6 @@ package naksha.psql import naksha.model.Action -import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.request.OrderBy import naksha.model.request.ReadFeatures @@ -29,7 +28,7 @@ class ReadOrderedTest : PgTestBase() { val featuresToCreate = randomFeatures(COUNT) val writeFeaturesReq = WriteRequest().apply { featuresToCreate.forEach { featureToCreate -> - add(Write().createFeature(collection.mapId, collection.id, featureToCreate)) + add(Write().createFeature(collection.catalogId, collection.id, featureToCreate)) } } val writeFeaturesResp = executeWrite(writeFeaturesReq) @@ -37,7 +36,7 @@ class ReadOrderedTest : PgTestBase() { for (feature in writeFeaturesResp.features) { assertNotNull(feature) assertNull(allFeatures[feature.id]) - assertEquals(Action.CREATED, feature.properties.xyz.action) + assertEquals(Action.CREATE, feature.properties.xyz.action) allFeatures[feature.id] = feature allFeaturesOrderedByIdAsc.add(feature) allFeaturesOrderedByIdDesc.add(feature) @@ -49,8 +48,8 @@ class ReadOrderedTest : PgTestBase() { @Test fun searchOrderedById() { executeRead(ReadFeatures().apply { - mapId = TEST_MAP_ID - collectionIds += collection.id + catalogId = TEST_MAP_ID + collectionId += collection.id orderBy = OrderBy.id() limit = ORDER_BY_ID_LIMIT }).apply { @@ -63,8 +62,8 @@ class ReadOrderedTest : PgTestBase() { } executeRead(ReadFeatures().apply { - mapId = TEST_MAP_ID - collectionIds += collection.id + catalogId = TEST_MAP_ID + collectionId += collection.id orderBy = OrderBy(MetaColumn.id(), order = SortOrder.ASCENDING) limit = ORDER_BY_ID_LIMIT }).apply { diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/RecreateAfterDeleteTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/RecreateAfterDeleteTest.kt index 8969ab924d..1b1486281c 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/RecreateAfterDeleteTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/RecreateAfterDeleteTest.kt @@ -8,7 +8,6 @@ import naksha.model.request.Write import naksha.model.request.WriteRequest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull /** * Verifies the auto-purge behaviour when a feature is re-created after deletion. @@ -31,7 +30,7 @@ class RecreateAfterDeleteTest : PgTestBase() { WriteRequest().add(Write().createFeature(collection, NakshaFeature(featureId))) ).features.first()!! assertEquals(featureId, created.id) - assertEquals(Action.CREATED, created.properties.xyz.action) + assertEquals(Action.CREATE, created.properties.xyz.action) assertEquals(1, created.properties.xyz.changeCount) // Step 2: UPDATE — build on the returned created feature so cc is correct @@ -41,12 +40,12 @@ class RecreateAfterDeleteTest : PgTestBase() { ) Naksha.cache.clear() val updated = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId }).features.first()!! assertEquals(featureId, updated.id) - assertEquals(Action.UPDATED, updated.properties.xyz.action) + assertEquals(Action.UPDATE, updated.properties.xyz.action) // Step 3: DELETE — tombstone now lives in HEAD executeWrite( @@ -55,19 +54,19 @@ class RecreateAfterDeleteTest : PgTestBase() { // Confirm tombstone is visible via queryDeleted=true Naksha.cache.clear() val deleted = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId queryDeleted = true }).features.first()!! assertEquals(featureId, deleted.id) - assertEquals(Action.DELETED, deleted.properties.xyz.action) + assertEquals(Action.DELETE, deleted.properties.xyz.action) // Confirm feature is invisible in a normal read Naksha.cache.clear() val notFound = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId }) assertEquals(0, notFound.features.size) @@ -79,50 +78,50 @@ class RecreateAfterDeleteTest : PgTestBase() { })) ).features.first()!! assertEquals(featureId, recreated.id) - assertEquals(Action.CREATED, recreated.properties.xyz.action) + assertEquals(Action.CREATE, recreated.properties.xyz.action) // cc resets to 1 for the new lifecycle assertEquals(1, recreated.properties.xyz.changeCount) // Confirm feature is visible again in a normal read Naksha.cache.clear() val found = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId }) assertEquals(1, found.features.size) - assertEquals(Action.CREATED, found.features[0]!!.properties.xyz.action) + assertEquals(Action.CREATE, found.features[0]!!.properties.xyz.action) // queryHistory=true returns current HEAD (live CREATED) + all history entries. // History after auto-purge: DELETED (archived tombstone), UPDATED, CREATED (old lifecycle). // Total = 1 (HEAD) + 3 (history) = 4, in descending version order. val historyOnly = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId queryHistory = true versions = 10 }) assertEquals(4, historyOnly.features.size) - assertEquals(Action.CREATED, historyOnly.features[0]!!.properties.xyz.action) // new HEAD - assertEquals(Action.DELETED, historyOnly.features[1]!!.properties.xyz.action) // archived tombstone - assertEquals(Action.UPDATED, historyOnly.features[2]!!.properties.xyz.action) - assertEquals(Action.CREATED, historyOnly.features[3]!!.properties.xyz.action) + assertEquals(Action.CREATE, historyOnly.features[0]!!.properties.xyz.action) // new HEAD + assertEquals(Action.DELETE, historyOnly.features[1]!!.properties.xyz.action) // archived tombstone + assertEquals(Action.UPDATE, historyOnly.features[2]!!.properties.xyz.action) + assertEquals(Action.CREATE, historyOnly.features[3]!!.properties.xyz.action) // queryHistory + queryDeleted: same result — no tombstone in HEAD (was auto-purged), // so queryDeleted=true adds nothing here. val full = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += featureId queryHistory = true queryDeleted = true versions = 10 }) assertEquals(4, full.features.size) - assertEquals(Action.CREATED, full.features[0]!!.properties.xyz.action) - assertEquals(Action.DELETED, full.features[1]!!.properties.xyz.action) - assertEquals(Action.UPDATED, full.features[2]!!.properties.xyz.action) - assertEquals(Action.CREATED, full.features[3]!!.properties.xyz.action) + assertEquals(Action.CREATE, full.features[0]!!.properties.xyz.action) + assertEquals(Action.DELETE, full.features[1]!!.properties.xyz.action) + assertEquals(Action.UPDATE, full.features[2]!!.properties.xyz.action) + assertEquals(Action.CREATE, full.features[3]!!.properties.xyz.action) } } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/TupleNumberPersistenceTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/TupleNumberPersistenceTest.kt index f35ffca906..5a732f5239 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/TupleNumberPersistenceTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/TupleNumberPersistenceTest.kt @@ -7,12 +7,10 @@ import naksha.model.Action import naksha.model.Naksha import naksha.model.Naksha.NakshaCompanion.featureNumber import naksha.model.Naksha.NakshaCompanion.partitionNumber -import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.request.Write import naksha.model.request.WriteRequest import naksha.model.RandomFeatures -import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID import kotlin.test.* class TupleNumberPersistenceTest : PgTestBase(collection = null, mapId = "") { @@ -69,12 +67,12 @@ class TupleNumberPersistenceTest : PgTestBase(collection = null, mapId = "") { // And: `storeNumber` checks out storage.adminConnection().use { conn -> - val pgMap = storage.adminMap.getPgMapById(conn, collection.mapId!!) - require(pgMap != null) { "Missing map ${collection.mapId}" } + val pgMap = storage.adminCatalog.getPgCatalogById(conn, collection.catalogId!!) + require(pgMap != null) { "Missing map ${collection.catalogId}" } val pgCollection = pgMap.getPgCollectionById(conn, collection.id) require(pgCollection != null) { "Missing collection ${collection.id}" } - assertEquals(storage.number, persistedTuple.tupleNumber.storageNumber) - assertEquals(pgMap.number, persistedTuple.tupleNumber.mapNumber) + assertEquals(storage.number, persistedTuple.tupleNumber.databaseNumber) + assertEquals(pgMap.number, persistedTuple.tupleNumber.catalogNumber) assertEquals(pgCollection.number, persistedTuple.tupleNumber.collectionNumber) assertEquals(featureNumber(feature.id), persistedTuple.tupleNumber.featureNumber) assertEquals(partitionNumber(featureNumber(feature.id)), persistedTuple.tupleNumber.partitionNumber) @@ -106,8 +104,8 @@ class TupleNumberPersistenceTest : PgTestBase(collection = null, mapId = "") { featureTuples.filterNotNull().forEach { featureTuple -> val tuple = featureTuple.tuple assertNotNull(tuple) - assertEquals(featuresById[featureTuple.id]?.id, tuple.getStringMember(naksha.model.objects.StandardMembers.Id)) - assertEquals(Action.CREATED, featureTuple.tupleNumber.action) + assertEquals(featuresById[featureTuple.id]?.id, tuple.getString(naksha.model.objects.StandardMembers.Id)) + assertEquals(Action.CREATE, featureTuple.tupleNumber.action) } } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpdateFeatureTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpdateFeatureTest.kt index 83b15b7857..4f6bbb5bc3 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpdateFeatureTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpdateFeatureTest.kt @@ -57,7 +57,7 @@ class UpdateFeatureTest : PgTestBase(collection = null, mapId = "") { retrievedXyz .hasProperty("appId", PgTest.TEST_APP_ID) .hasProperty("author", PgTest.TEST_APP_AUTHOR) - .hasProperty("action", Action.UPDATED.text) + .hasProperty("action", Action.UPDATE.text) .hasProperty("changeCount", 2) } } @@ -93,8 +93,8 @@ class UpdateFeatureTest : PgTestBase(collection = null, mapId = "") { // READ FEATURE HISTORY Naksha.cache.clear() val readResp = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += initialFeature.id queryHistory = true }) @@ -103,31 +103,31 @@ class UpdateFeatureTest : PgTestBase(collection = null, mapId = "") { assertNotNull(retrievedTuples) assertEquals(2, retrievedTuples.size) - val createdTuple = retrievedTuples.first { Action.fromValue((it.getLongMember(naksha.model.objects.StandardMembers.Version).toInt() and 3) ?: -1) == Action.CREATED } - val updatedTuple = retrievedTuples.first { Action.fromValue((it.getLongMember(naksha.model.objects.StandardMembers.Version).toInt() and 3) ?: -1) == Action.UPDATED } + val createdTuple = retrievedTuples.first { Action.fromValue((it.getLong(naksha.model.objects.StandardMembers.Version).toInt() and 3) ?: -1) == Action.CREATE } + val updatedTuple = retrievedTuples.first { Action.fromValue((it.getLong(naksha.model.objects.StandardMembers.Version).toInt() and 3) ?: -1) == Action.UPDATE } // Then assertNotEquals(updatedTuple.tupleNumber.version, createdTuple.tupleNumber.version) - assertEquals(createdTuple.getLongMember(naksha.model.objects.StandardMembers.NextVersion), updatedTuple.tupleNumber.version.txn) - assertNull(updatedTuple.getLongMember(naksha.model.objects.StandardMembers.NextVersion, Int64(-1L)).let { if (it == Int64(-1L)) null else it }) + assertEquals(createdTuple.getLong(naksha.model.objects.StandardMembers.NextVersion), updatedTuple.tupleNumber.version.value) + assertNull(updatedTuple.getLong(naksha.model.objects.StandardMembers.NextVersion, Int64(-1L)).let { if (it == Int64(-1L)) null else it }) // Both tuples share the collection's feature encoding (action lives in version bits). assertEquals(createdTuple.dataEncoding, updatedTuple.dataEncoding) - assertEquals(1, createdTuple.getIntMember(naksha.model.objects.StandardMembers.ChangeCount)) - assertEquals(2, updatedTuple.getIntMember(naksha.model.objects.StandardMembers.ChangeCount)) + assertEquals(1, createdTuple.getInt(naksha.model.objects.StandardMembers.ChangeCountXyz)) + assertEquals(2, updatedTuple.getInt(naksha.model.objects.StandardMembers.ChangeCountXyz)) assertEquals(createdTuple.getByteArray(naksha.model.objects.StandardMembers.Geometry), updatedTuple.getByteArray(naksha.model.objects.StandardMembers.Geometry)) - assertEquals(createdTuple.getStringMember(naksha.model.objects.StandardMembers.Tags), updatedTuple.getStringMember(naksha.model.objects.StandardMembers.Tags)) - assertNotEquals(createdTuple.feature, updatedTuple.feature) + assertEquals(createdTuple.getString(naksha.model.objects.StandardMembers.XyzTags), updatedTuple.getString(naksha.model.objects.StandardMembers.XyzTags)) + assertNotEquals(createdTuple.featureBytes, updatedTuple.featureBytes) assertEquals(createdTuple.getByteArray(naksha.model.objects.StandardMembers.ReferencePoint), updatedTuple.getByteArray(naksha.model.objects.StandardMembers.ReferencePoint)) - assertNull(createdTuple.toNakshaFeature()?.properties["new_attr"]) - assertEquals("some_value", updatedTuple.toNakshaFeature()?.properties["new_attr"]) - assertEquals(createdTuple.getLongMember(naksha.model.objects.StandardMembers.CreatedAt)?.let { if (it == Int64(0L)) null else it } ?: createdTuple.getLongMember(naksha.model.objects.StandardMembers.UpdatedAt), updatedTuple.getLongMember(naksha.model.objects.StandardMembers.CreatedAt)?.let { if (it == Int64(0L)) null else it }) - assertNotEquals(updatedTuple.getLongMember(naksha.model.objects.StandardMembers.CreatedAt), updatedTuple.getLongMember(naksha.model.objects.StandardMembers.UpdatedAt)) - assertNull(createdTuple.getLongMember(naksha.model.objects.StandardMembers.CreatedAt)?.let { if (it == Int64(0L)) null else it }) - assertNotNull(createdTuple.getLongMember(naksha.model.objects.StandardMembers.UpdatedAt)) - assertEquals(createdTuple.getIntMember(naksha.model.objects.StandardMembers.HereTile), updatedTuple.getIntMember(naksha.model.objects.StandardMembers.HereTile)) - assertEquals(Action.UPDATED, updatedTuple.tupleNumber.action) - assertEquals(Action.CREATED, createdTuple.tupleNumber.action) - assertNotEquals(createdTuple.getLongMember(naksha.model.objects.StandardMembers.AuthorTimestamp), updatedTuple.getLongMember(naksha.model.objects.StandardMembers.AuthorTimestamp)) + assertNull(createdTuple.decodeFeature()?.properties["new_attr"]) + assertEquals("some_value", updatedTuple.decodeFeature()?.properties["new_attr"]) + assertEquals(createdTuple.getLong(naksha.model.objects.StandardMembers.CreatedAtXyz)?.let { if (it == Int64(0L)) null else it } ?: createdTuple.getLong(naksha.model.objects.StandardMembers.XyzUpdatedAt), updatedTuple.getLong(naksha.model.objects.StandardMembers.CreatedAtXyz)?.let { if (it == Int64(0L)) null else it }) + assertNotEquals(updatedTuple.getLong(naksha.model.objects.StandardMembers.CreatedAtXyz), updatedTuple.getLong(naksha.model.objects.StandardMembers.XyzUpdatedAt)) + assertNull(createdTuple.getLong(naksha.model.objects.StandardMembers.CreatedAtXyz)?.let { if (it == Int64(0L)) null else it }) + assertNotNull(createdTuple.getLong(naksha.model.objects.StandardMembers.XyzUpdatedAt)) + assertEquals(createdTuple.getInt(naksha.model.objects.StandardMembers.HereTileXyz), updatedTuple.getInt(naksha.model.objects.StandardMembers.HereTileXyz)) + assertEquals(Action.UPDATE, updatedTuple.tupleNumber.action) + assertEquals(Action.CREATE, createdTuple.tupleNumber.action) + assertNotEquals(createdTuple.getLong(naksha.model.objects.StandardMembers.XyzAuthorTimestamp), updatedTuple.getLong(naksha.model.objects.StandardMembers.XyzAuthorTimestamp)) } @Test @@ -260,8 +260,8 @@ class UpdateFeatureTest : PgTestBase(collection = null, mapId = "") { private fun fetchSingleFeature(id: String): NakshaFeature { Naksha.cache.clear() val readFeatureResp = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += id }) assertEquals(1, readFeatureResp.length) diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpsertFeatureTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpsertFeatureTest.kt index 758df1671b..5773a6e9eb 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpsertFeatureTest.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/UpsertFeatureTest.kt @@ -1,13 +1,11 @@ package naksha.psql import naksha.model.Action -import naksha.model.objects.NakshaCollection import naksha.model.objects.NakshaFeature import naksha.model.request.ReadFeatures import naksha.model.request.SuccessResponse import naksha.model.request.Write import naksha.model.request.WriteRequest -import naksha.psql.PgTest.PgTest_C.TEST_MAP_ID import naksha.psql.assertions.NakshaFeatureFluidAssertions.Companion.assertThatFeature import kotlin.test.Test import kotlin.test.assertEquals @@ -37,11 +35,11 @@ class UpsertFeatureTest : PgTestBase() { // And: Retrieving feature by id val retrievedFeatures = executeRead(ReadFeatures().apply { - mapId = collection.mapId - collectionIds += collection.id + catalogId = collection.catalogId + collectionId += collection.id featureIds += initialFeature.id queryHistory = true - }).features.sortedBy { it!!.properties.xyz.version!!.txn.toLong() } + }).features.sortedBy { it!!.properties.xyz.version!!.number.toLong() } // Then assertThatFeature(retrievedFeatures[0]!!) @@ -54,7 +52,7 @@ class UpsertFeatureTest : PgTestBase() { .hasFeatureType(initialFeature.properties.featureType) .hasXyzThat { retrievedXyz -> retrievedXyz - .hasProperty("action", Action.CREATED.text) + .hasProperty("action", Action.CREATE.text) .hasProperty("changeCount", 1) } } @@ -69,7 +67,7 @@ class UpsertFeatureTest : PgTestBase() { .hasFeatureType(initialFeature.properties.featureType) .hasXyzThat { retrievedXyz -> retrievedXyz - .hasProperty("action", Action.UPDATED.text) + .hasProperty("action", Action.UPDATE.text) .hasProperty("changeCount", 2) } } @@ -105,7 +103,7 @@ class UpsertFeatureTest : PgTestBase() { // TODO: only the first one is updated response.features.forEach { feature -> assertNotNull(feature) - assertEquals(Action.UPDATED, feature.properties.xyz.action) + assertEquals(Action.UPDATE, feature.properties.xyz.action) } } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Cursor.kt b/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Cursor.kt index fd0865d2fd..0bfcd809f4 100644 --- a/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Cursor.kt +++ b/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Cursor.kt @@ -27,6 +27,10 @@ class Plv8Cursor: PgCursor { TODO("Not yet implemented") } + override fun columnNames(): Array { + TODO("Not yet implemented") + } + override fun contains(name: String): Boolean { TODO("Not yet implemented") } diff --git a/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Storage.kt b/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Storage.kt index ec1decd849..8e0dfbf083 100644 --- a/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Storage.kt +++ b/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Storage.kt @@ -36,10 +36,6 @@ class Plv8Storage : PgStorage() { TODO("Not yet implemented") } - override fun getDataEncoding(feature: Any?, context: Any?): DataEncoding { - TODO("Not yet implemented") - } - override fun getDictionary(id: String): JbDictionary? { TODO("Not yet implemented") } diff --git a/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Trigger.kt b/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Trigger.kt index 7f2c7257e5..a910348a01 100644 --- a/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Trigger.kt +++ b/here-naksha-lib-psql/src/jsMain/kotlin/naksha/psql/Plv8Trigger.kt @@ -20,7 +20,7 @@ internal const val TG_LEVEL_ROW = "ROW" internal const val TG_LEVEL_STATEMENT = "STATEMENT" /** - * The raw row as returned by PostgresQL triggers. Check [PgColumn.allColumns], it should match this. + * The raw row as returned by PostgresQL triggers. Check [PgColumn_C.allColumns], it should match this. */ @JsPlainObject external interface Plv8Row { diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlAdminMap.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlAdminCatalog.kt similarity index 85% rename from here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlAdminMap.kt rename to here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlAdminCatalog.kt index fbd6e1547d..d5973ceba8 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlAdminMap.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlAdminCatalog.kt @@ -4,8 +4,6 @@ import naksha.base.* import naksha.base.Platform.PlatformCompanion.logger import naksha.jbon.JbDictionary import naksha.model.* -import naksha.model.objects.NakshaCollection -import naksha.model.objects.NakshaMap import naksha.psql.PgUtil.PgUtilCompanion.quoteLiteral /** @@ -13,38 +11,16 @@ import naksha.psql.PgUtil.PgUtilCompanion.quoteLiteral * @since 3.0 */ @Suppress("MemberVisibilityCanBePrivate") -class PsqlAdminMap internal constructor( +class PsqlAdminCatalog internal constructor( storage: PgStorage, config: PgConfig, create: Boolean?, upgrade: Boolean? -) : PgAdminMap(storage, config, create, upgrade) { +) : PgAdminCatalog(storage, config, create, upgrade) { override val storage: PsqlStorage get() = super.storage as PsqlStorage - override fun getDataEncoding(feature: Any?, context: Any?): DataEncoding { - if (context is PgCollection) { - var encoding = context.head.dataEncoding - if (encoding == null) encoding = context.map.head.dataEncoding - return encoding ?: Naksha.DEFAULT_DATA_ENCODING - } - if (context is PgMap) { - return context.head.dataEncoding ?: Naksha.DEFAULT_DATA_ENCODING - } - if (context is NakshaCollection) { - val collectionEncoding = context.dataEncoding - if (collectionEncoding != null) return collectionEncoding - val mapId = context.mapId ?: return Naksha.DEFAULT_DATA_ENCODING - val pgMap = getPgMapById(null, mapId) - return pgMap?.head?.dataEncoding ?: Naksha.DEFAULT_DATA_ENCODING - } - if (context is NakshaMap) { - return context.dataEncoding ?: Naksha.DEFAULT_DATA_ENCODING - } - return Naksha.DEFAULT_DATA_ENCODING - } - override fun getDictionary(id: String): JbDictionary? { // TODO: Implement me! return null @@ -55,11 +31,11 @@ class PsqlAdminMap internal constructor( return null } - override fun createAdminMap(conn: PgConnection, config: PgConfig, storageId: String, storageNumber: Int64, psqlVersion: NakshaVersion): Int { + override fun createAdminCatalog(conn: PgConnection, config: PgConfig, storageId: String, storageNumber: Int64, psqlVersion: NakshaVersion): Int { return upsertAdminMap(conn, storageId, storageNumber, psqlVersion, null, null) } - override fun upgradeAdminMap(conn: PgConnection, config: PgConfig, storageId: String, storageNumber: Int64, psqlVersion: NakshaVersion, schemaOid: Int, installedVersion: NakshaVersion?) { + override fun upgradeAdminCatalog(conn: PgConnection, config: PgConfig, storageId: String, storageNumber: Int64, psqlVersion: NakshaVersion, schemaOid: Int, installedVersion: NakshaVersion?) { upsertAdminMap(conn, storageId, storageNumber, psqlVersion, schemaOid, installedVersion) } @@ -180,7 +156,8 @@ class PsqlAdminMap internal constructor( ) ) logger.info("Create transaction-seq, map-sequence, and collection-sequence ...") - conn.execute("CREATE SEQUENCE IF NOT EXISTS $NAKSHA_TXN_SEQ AS ${PgType.INT64} START 4 INCREMENT BY 4 CACHE 40;").close() + // For a version number, the lower two bit must be always set. + conn.execute("CREATE SEQUENCE IF NOT EXISTS $NAKSHA_VERSION_SEQ AS ${PgType.INT64} START 3 INCREMENT BY 4 CACHE 40;").close() logger.info("Create internal collections: transactions, collections, and dictionaries") createPgCollection(conn, collections) // 0 diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlMap.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCatalog.kt similarity index 93% rename from here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlMap.kt rename to here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCatalog.kt index e96c1ce568..f1d7b2dad1 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlMap.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCatalog.kt @@ -2,31 +2,26 @@ package naksha.psql import com.github.benmanes.caffeine.cache.* import naksha.base.* -import naksha.model.Naksha -import naksha.model.Naksha.NakshaCompanion.ADMIN_MAP_NUMBER -import naksha.model.Naksha.NakshaCompanion.CATALOGS_COL_NUMBER import naksha.model.illegalArg -import naksha.model.objects.NakshaMap -import naksha.psql.PgColumn.PgColumnCompanion.allColumns import java.util.concurrent.TimeUnit /** * A cache for a specific map, which by itself will cache the collections. * - * @property adminMap the admin-map to which this cache entry belongs. + * @property adminCatalog the admin-map to which this cache entry belongs. * @property id the map-id. * @property number the map-number. * @since 3.0 */ -data class PsqlMap( - val adminMap: PsqlAdminMap, - val pgMap: PgMap? = null, - val id: String = pgMap?.id ?: throw illegalArg("PsqlMap without valid id"), - val number: Int = pgMap?.number ?: throw illegalArg("PsqlMap without valid number") +data class PsqlCatalog( + val adminCatalog: PsqlAdminCatalog, + val pgCatalog: PgCatalog? = null, + val id: String = pgCatalog?.id ?: throw illegalArg("PsqlCatalog without valid id"), + val number: Int = pgCatalog?.catalogNumber ?: throw illegalArg("PsqlCatalog without valid number") ): Expiry { /** - * Tests if the underlying [PgMap] still exist. + * Tests if the underlying [PgCatalog] still exist. * @return `true` if the map exists; `false` if this is a tombstone cache entry. */ fun exists(): Boolean = head.get() != null @@ -34,7 +29,7 @@ data class PsqlMap( /** * The current HEAD state, _null_ if the map does not exist _(after being deleted)_. */ - val head = AtomicRef(pgMap) + val head = AtomicRef(pgCatalog) // ----------------------------< Children management aka collection caching >------------------------------------------ diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCollection.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCollection.kt index ce2135c53d..f2010770bd 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCollection.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCollection.kt @@ -6,7 +6,7 @@ import naksha.base.AtomicRef * A cache entry for a [PgCollection]. * @since 3.0 */ -data class PsqlCollection(val psqlMap: PsqlMap, val id: String, val number: Number) { +data class PsqlCollection(val psqlCatalog: PsqlCatalog, val id: String, val number: Number) { /** * Tests if the underlying [PgCollection] exist. * @return `true` if the collection exists; `false` if this is a tombstone cache entry. diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCursor.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCursor.kt index 8e5bf2662b..26a1d0208a 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCursor.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlCursor.kt @@ -97,7 +97,7 @@ class PsqlCursor internal constructor(private val stmt: Statement, private val c /** * The names of the columns, indexed from 0, while in a SQL statement the index starts with 1. */ - private fun columnNames(): Array = + override fun columnNames(): Array = columnNames ?: throw IllegalStateException("Initialization error: Missing column names array") /** diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorage.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorage.kt index c7f592a0c6..1a28a35244 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorage.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorage.kt @@ -15,8 +15,8 @@ open class PsqlStorage : PgStorage(), IStorage { override val configKlass: KClass = PgConfig::class - override val adminMap: PsqlAdminMap - get() = super.adminMap as PsqlAdminMap + override val adminCatalog: PsqlAdminCatalog + get() = super.adminCatalog as PsqlAdminCatalog private var _cluster: PgCluster? = null @@ -55,22 +55,20 @@ open class PsqlStorage : PgStorage(), IStorage { _cluster = c } setAdminMap(newAdminMap(config, create, upgrade)) - adminMap.start() + adminCatalog.start() } - protected open fun newAdminMap(config: PgConfig, create: Boolean?, upgrade: Boolean?): PsqlAdminMap - = PsqlAdminMap(this, config, create, upgrade) + protected open fun newAdminMap(config: PgConfig, create: Boolean?, upgrade: Boolean?): PsqlAdminCatalog + = PsqlAdminCatalog(this, config, create, upgrade) override fun newSession(options: SessionOptions, readOnly: Boolean): PgSession { useInitialized() return PgSession(this, options, readOnly) } - override fun getDataEncoding(feature: Any?, context: Any?): DataEncoding = adminMap.getDataEncoding(feature, context) + override fun getDictionary(id: String): JbDictionary? = adminCatalog.getDictionary(id) - override fun getDictionary(id: String): JbDictionary? = adminMap.getDictionary(id) - - override fun getEncodingDictionary(feature: Any?, context: Any?): JbDictionary? = adminMap.getEncodingDictionary(feature, context) + override fun getEncodingDictionary(feature: Any?, context: Any?): JbDictionary? = adminCatalog.getEncodingDictionary(feature, context) override fun newConnection(options: SessionOptions, readOnly: Boolean, init: Fx2?): PgConnection = cluster.newConnection(options, readOnly, init) diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorageListener.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorageListener.kt index abaee2c79a..22be5f99c7 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorageListener.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlStorageListener.kt @@ -13,7 +13,7 @@ internal class PsqlStorageListener(storage: PsqlStorage) : Thread("lib-psql-list private val storageRef: WeakReference = WeakReference(storage) private val shutdown = AtomicBoolean(false) private val adminOptions = Naksha.adminOptions - private val adminMap = storage.adminMap as PsqlAdminMap + private val adminMap = storage.adminCatalog as PsqlAdminCatalog private val cluster = storage.cluster private val e = Exception() diff --git a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlTestStorage.kt b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlTestStorage.kt index f89fc1224c..42a443902c 100644 --- a/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlTestStorage.kt +++ b/here-naksha-lib-psql/src/jvmMain/kotlin/naksha/psql/PsqlTestStorage.kt @@ -169,7 +169,7 @@ class PsqlTestStorage : PsqlStorage() { super.initStorage(config, create, upgrade) } - override fun newAdminMap(config: PgConfig, create: Boolean?, upgrade: Boolean?): PsqlAdminMap { - return PsqlAdminMap(this, config, create, upgrade) + override fun newAdminMap(config: PgConfig, create: Boolean?, upgrade: Boolean?): PsqlAdminCatalog { + return PsqlAdminCatalog(this, config, create, upgrade) } } \ No newline at end of file diff --git a/here-naksha-lib-psql/src/jvmTest/java/naksha/psql/DeleteFeatureByVersionTest.java b/here-naksha-lib-psql/src/jvmTest/java/naksha/psql/DeleteFeatureByVersionTest.java index c989c78d37..957d406417 100644 --- a/here-naksha-lib-psql/src/jvmTest/java/naksha/psql/DeleteFeatureByVersionTest.java +++ b/here-naksha-lib-psql/src/jvmTest/java/naksha/psql/DeleteFeatureByVersionTest.java @@ -151,8 +151,8 @@ void shouldFailEntireRequestIfPartialDeletionFails() { private NakshaFeatureList getFeatureByIds(String... ids) { ReadFeatures readAll = new ReadFeatures() - .withMapId(getCollection().getMapId()) - .addCollectionId(getCollection().getId()); + .withCatalogId(getCollection().getCatalogId()) + .withCollectionId(getCollection().getId()); readAll.setFeatureIds(StringList.of(ids)); return executeRead(readAll, newSessionOptions()).getFeatures(); } diff --git a/here-naksha-lib-psql/src/jvmTest/kotlin/naksha/psql/PsqlErrorMappingTest.kt b/here-naksha-lib-psql/src/jvmTest/kotlin/naksha/psql/PsqlErrorMappingTest.kt index 566e6bb5fb..036ee0b3ca 100644 --- a/here-naksha-lib-psql/src/jvmTest/kotlin/naksha/psql/PsqlErrorMappingTest.kt +++ b/here-naksha-lib-psql/src/jvmTest/kotlin/naksha/psql/PsqlErrorMappingTest.kt @@ -34,7 +34,7 @@ class PsqlErrorMappingTest : PgTestBase() { fun shouldReturnConflictingCollectionError() { // Given val createAlreadyExistingCollection = WriteRequest().add( - Write().createCollection(NakshaCollection(collection.id, collection.mapId)) + Write().createCollection(NakshaCollection(collection.id, collection.catalogId)) ) // When diff --git a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/View.java b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/View.java index dc67c71020..3918cfedb8 100644 --- a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/View.java +++ b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/View.java @@ -57,7 +57,6 @@ public void setViewLayerCollection(@NotNull ViewLayerCollection viewLayerCollect this.viewLayerCollection = viewLayerCollection; } - @Override public @NotNull naksha.model.DataEncoding getDataEncoding(@Nullable Object feature, @Nullable Object context) { throw new NotImplementedException("Not supported by View storage"); } diff --git a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewLayer.java b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewLayer.java index 94a9db412b..7247f5006f 100644 --- a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewLayer.java +++ b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewLayer.java @@ -53,7 +53,7 @@ public ViewLayer(@NotNull IStorage storage, @Nullable String mapId, @NotNull Str * @since 2.0 */ public ViewLayer(@NotNull IStorage storage, @NotNull NakshaCollection collection) { - this(storage, collection.getMapId(), collection.getId()); + this(storage, collection.getCatalogId(), collection.getId()); } /** diff --git a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewReadSession.java b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewReadSession.java index d45c504fc4..dbb72fc296 100644 --- a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewReadSession.java +++ b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewReadSession.java @@ -29,9 +29,9 @@ import java.util.*; import naksha.model.*; +import naksha.model.MemberProcessorMap; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; -import naksha.model.objects.NakshaStorage; +import naksha.model.objects.NakshaCatalog; import naksha.model.request.*; import naksha.model.request.query.AnyOp; import naksha.model.request.query.IPropertyQuery; @@ -189,7 +189,7 @@ private boolean isRequestOnlyById(ReadRequest request) { } if (propertyQuery instanceof PQuery) { final PQuery query = ((PQuery) propertyQuery); - return query.getProperty().getPath().contains(Property.ID) + return query.getProperty().getPath().contains("id") && query.getOp().equals(AnyOp.IS_ANY_OF); } } @@ -235,12 +235,8 @@ public Response executeParallel(@NotNull Request request) { return execute(request); } - public void loadTuples(@NotNull List featureTuples) { - loadTuples(featureTuples, 0, featureTuples.size(), FETCH_ALL); - } - @Override - public void loadTuples(@NotNull List featureTuples, int from, int to, int mode) { + public void loadTuples(@NotNull List featureTuples, int from, int to) { final @NotNull ViewLayerCollection viewCollection = view.getViewCollection(); // TODO: We need to group the tuples by layer using: // viewCollection.getByTupleNumber() @@ -257,22 +253,22 @@ public void loadTuples(@NotNull List featureTuples, int } @Override - public @Nullable NakshaMap getMapById(@NotNull String mapId) { + public @Nullable NakshaCatalog getCatalogById(@NotNull String catalogId) { throw new UnsupportedOperationException(); } @Override - public @Nullable NakshaMap getMapByNumber(int mapNumber) { + public @Nullable NakshaCatalog getCatalogByNumber(int catalogNumber) { throw new UnsupportedOperationException(); } @Override - public @Nullable NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { + public @Nullable NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { throw new UnsupportedOperationException(); } @Override - public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { + public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { throw new UnsupportedOperationException(); } @@ -280,4 +276,9 @@ public void loadTuples(@NotNull List featureTuples, int public @NotNull SessionOptions getOptions() { throw new UnsupportedOperationException(); } + + @Override + public @NotNull MemberProcessorMap getProcessors() { + throw new UnsupportedOperationException(); + } } diff --git a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewWriteSession.java b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewWriteSession.java index c649307eca..29b2fdbaa2 100644 --- a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewWriteSession.java +++ b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/ViewWriteSession.java @@ -63,8 +63,8 @@ public ViewWriteSession withWriteLayer(ViewLayer viewLayer) { } } else if (request instanceof ReadFeatures) { final ReadFeatures readFeatures = (ReadFeatures) request; - readFeatures.setMapId(writeLayer.getMapId()); - readFeatures.setCollectionIds(new StringList(writeLayer.getCollectionId())); + readFeatures.setCatalogId(writeLayer.getMapId()); + readFeatures.setCollectionId(writeLayer.getCollectionId()); } else { throw new IllegalArgumentException("Unsupported request type: " + request.getClass()); } diff --git a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/LayerReadRequest.java b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/LayerReadRequest.java index 673e5d9832..ac69763fc8 100644 --- a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/LayerReadRequest.java +++ b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/LayerReadRequest.java @@ -34,8 +34,8 @@ public LayerReadRequest(@NotNull ReadFeatures request, @NotNull ViewLayer viewLa // Note: We need to copy the request, because we need to ignore the map/collection client asked for, // because the view is always fixed to certain map/collection! this.request = request.copy(false); - this.request.setMapId(viewLayer.getMapId()); - this.request.setCollectionIds(new StringList(viewLayer.getCollectionId())); + this.request.setCatalogId(viewLayer.getMapId()); + this.request.setCollectionId(viewLayer.getCollectionId()); this.viewLayer = viewLayer; this.session = session; } diff --git a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/ParallelQueryExecutor.java b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/ParallelQueryExecutor.java index 3f1607594d..a513a3c10d 100644 --- a/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/ParallelQueryExecutor.java +++ b/here-naksha-lib-view/src/jvmMain/java/com/here/naksha/lib/view/concurrent/ParallelQueryExecutor.java @@ -38,12 +38,10 @@ import java.util.stream.Stream; import naksha.base.Int64; -import naksha.base.Platform; import naksha.base.StringList; import naksha.model.*; import naksha.model.request.*; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -114,8 +112,8 @@ private Stream executeSingle( int layerPriority = view.getViewCollection().priorityOf(layer); final String collectionId = layer.getCollectionId(); final ReadFeatures readRequest = request.copy(false); - readRequest.setMapId(layer.getMapId()); - readRequest.setCollectionIds(new StringList(collectionId)); + readRequest.setCatalogId(layer.getMapId()); + readRequest.setCollectionId(collectionId); final @NotNull Response readResponse = session.execute(readRequest); final FeatureTupleList featureList = getFeatureTuples(readResponse); diff --git a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/MockReadSession.java b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/MockReadSession.java index 9cd4fb278f..bf48a319f3 100644 --- a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/MockReadSession.java +++ b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/MockReadSession.java @@ -24,7 +24,7 @@ import naksha.model.IStorage; import naksha.model.SessionOptions; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.request.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -103,22 +103,22 @@ public boolean isClosed() { } @Override - public @Nullable NakshaMap getMapById(@NotNull String mapId) { + public @Nullable NakshaCatalog getMapById(@NotNull String mapId) { return null; } @Override - public @Nullable NakshaMap getMapByNumber(int mapNumber) { + public @Nullable NakshaCatalog getCatalogByNumber(int catalogNumber) { return null; } @Override - public @Nullable NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { + public @Nullable NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { return null; } @Override - public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { + public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { return null; } diff --git a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/PsqlTests.java b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/PsqlTests.java index 83e84c2c5b..f1ea2acb4c 100644 --- a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/PsqlTests.java +++ b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/PsqlTests.java @@ -25,7 +25,7 @@ import naksha.model.NakshaContext; import naksha.model.SessionOptions; import naksha.model.objects.NakshaFeature; -import naksha.model.objects.NakshaMap; +import naksha.model.objects.NakshaCatalog; import naksha.model.objects.NakshaStorage; import naksha.model.request.*; import org.jetbrains.annotations.NotNull; @@ -67,7 +67,7 @@ final boolean runTest() { public static final String TEST_APP_ID = "test_app"; public static final String TEST_AUTHOR = "test_author"; static IStorage storage; - static NakshaMap map; + static NakshaCatalog map; static @Nullable NakshaContext nakshaContext; protected static @NotNull SuccessResponse assertSuccess(@NotNull Response response) { @@ -107,10 +107,10 @@ static void beforeTest() { executeWrite(new WriteRequest().add(new Write().deleteMapById(TEST_MAP_ID))); // Create the map. - SuccessResponse response = executeWrite(new WriteRequest().add(new Write().createMap(new NakshaMap(TEST_MAP_ID)))); + SuccessResponse response = executeWrite(new WriteRequest().add(new Write().createMap(new NakshaCatalog(TEST_MAP_ID)))); assertEquals(1, response.getFeatures().size()); NakshaFeature raw = response.getFeatures().get(0); assertNotNull(raw); - map = javaProxy(raw, NakshaMap.class); + map = javaProxy(raw, NakshaCatalog.class); } } diff --git a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewTest.java b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewTest.java index 911cc63fe3..76a59758f4 100644 --- a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewTest.java +++ b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewTest.java @@ -58,7 +58,6 @@ import naksha.base.StringList; import naksha.model.Action; import naksha.model.IReadSession; -import naksha.model.ISession; import naksha.model.IStorage; import naksha.model.IWriteSession; import naksha.model.NakshaContext; @@ -78,7 +77,6 @@ import naksha.model.request.query.PQuery; import naksha.model.request.query.Property; import naksha.model.request.query.StringOp; -import naksha.model.util.CustomStoragePropertiesUtil; import naksha.model.util.RequestHelper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -148,14 +146,14 @@ void testWriteApiNotation() { request.add(write.createFeature(TEST_MAP_ID, "", feature)); // when(storage.tupleToFeature(any())).thenReturn(feature); - Response success = new SuccessResponse(sampleXyzWriteResponse(1, Action.CREATED)); + Response success = new SuccessResponse(sampleXyzWriteResponse(1, Action.CREATE)); when(session.execute(request)).thenReturn(success); ViewWriteSession writeSession = view.newWriteSession(sessionOptions); Response response = writeSession.execute(request); assertInstanceOf(SuccessResponse.class, response); SuccessResponse successResponse = (SuccessResponse) response; assertEquals(feature.getId(), successResponse.getFeatures().get(0).getId()); - assertEquals(Action.CREATED, successResponse.getFeatureTupleList().get(0).getFeature().getProperties().getXyz().getAction()); + assertEquals(Action.CREATE, successResponse.getFeatureTupleList().get(0).getFeature().getProperties().getXyz().getAction()); writeSession.commit(); } @@ -173,7 +171,7 @@ void testDeleteApiNotation() { final WriteRequest request = new WriteRequest(); final NakshaFeature feature = new NakshaFeature("0"); request.add(write.deleteFeatureById(topologiesDS.getMapId(), topologiesDS.getCollectionId(), feature.getId())); - SuccessResponse successResponse1 = new SuccessResponse(sampleXyzWriteResponse(1, Action.DELETED)); + SuccessResponse successResponse1 = new SuccessResponse(sampleXyzWriteResponse(1, Action.DELETE)); when(session.execute(request)).thenReturn(successResponse1); ViewWriteSession writeSession = view.newWriteSession(sessionOptions); @@ -181,7 +179,7 @@ void testDeleteApiNotation() { assertInstanceOf(SuccessResponse.class, response); SuccessResponse successResponse = (SuccessResponse) response; assertEquals(feature.getId(), successResponse.getFeatureTupleList().get(0).getId()); - assertEquals(Action.DELETED, successResponse.getFeatureTupleList().get(0).getFeature().getProperties().getXyz().getAction()); + assertEquals(Action.DELETE, successResponse.getFeatureTupleList().get(0).getFeature().getProperties().getXyz().getAction()); writeSession.commit(); } @@ -353,8 +351,8 @@ void shouldApplyCustomTimeoutsPerLayer() { // And ReadFeatures readFeatures = new ReadFeatures(); - readFeatures.setMapId(TEST_MAP_ID); - readFeatures.setCollectionIds(new StringList(firstLayer.getCollectionId(), secondLayer.getCollectionId(), thirdLayer.getCollectionId())); + readFeatures.setCatalogId(TEST_MAP_ID); + readFeatures.setCollectionId(new StringList(firstLayer.getCollectionId(), secondLayer.getCollectionId(), thirdLayer.getCollectionId())); // When new View(viewLayerCollection).newReadSession(sessionOptions).execute(readFeatures); diff --git a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewWriteSessionTests.java b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewWriteSessionTests.java index 6f203d9959..e349eef864 100644 --- a/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewWriteSessionTests.java +++ b/here-naksha-lib-view/src/jvmTest/java/com/here/naksha/lib/view/ViewWriteSessionTests.java @@ -110,8 +110,8 @@ void readAndWrite_UsingViewWriteSession() { NakshaFeature feature = response1.getFeatures().get(0); assertEquals(1d, ((PointCoord) feature.getGeometry().getCoordinates()).getLongitude()); assertTrue(feature.getProperties().containsKey("testProperty")); - assertEquals("test", feature.getProperties().get("testProperty").toString()); - assertSame(Action.UPDATED, response1.getFeatureTupleList().get(0).tuple.version.action()); + assertEquals("test", feature.getProperties().getPath("testProperty").toString()); + assertSame(Action.UPDATE, response1.getFeatureTupleList().get(0).tuple.version.action()); writeSession.commit(); @@ -124,7 +124,7 @@ void readAndWrite_UsingViewWriteSession() { NakshaFeature updatedFeature = list.get(0); assertEquals(1d, ((PointCoord) updatedFeature.getGeometry().getCoordinates()).getLongitude()); assertTrue(updatedFeature.getProperties().containsKey("testProperty")); - assertEquals("test", updatedFeature.getProperties().get("testProperty").toString()); + assertEquals("test", updatedFeature.getProperties().getPath("testProperty").toString()); } @Test @@ -153,7 +153,7 @@ void updateNonExistentFeatureCreatesFeature() { SuccessResponse response = (SuccessResponse) writeSession.execute(writeRequest); //THEN assertNotNull(response.getFeatureTupleList().get(0)); - assertSame(Action.CREATED, response.getFeatureTupleList().get(0).tuple.version.action()); + assertSame(Action.CREATE, response.getFeatureTupleList().get(0).tuple.version.action()); writeSession.commit(); //GIVEN Verify the feature was actually created in the top layer (collection_0) @@ -208,7 +208,7 @@ void writeFeatureOnSelectedLayer() { SuccessResponse response = (SuccessResponse) writeSession.execute(writeRequest); assertNotNull(response.getFeatureTupleList().get(0)); - assertSame(Action.CREATED, response.getFeatureTupleList().get(0).tuple.version.action()); + assertSame(Action.CREATE, response.getFeatureTupleList().get(0).tuple.version.action()); writeSession.commit(); //check if the newly added feature found on layer @@ -250,7 +250,7 @@ void deleteFeatureFromTopLayer() { Tuple tuple = featureTuple.tuple; assertNotNull(tuple); - assertSame(Action.DELETED, tuple.version.action()); + assertSame(Action.DELETE, tuple.version.action()); assertEquals("feature_id_view1", featureTuple.getId()); // TODO: Ones we have the loadTuples available, do: diff --git a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorage.java b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorage.java index 6e72c10960..92d7023f7c 100644 --- a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorage.java +++ b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorage.java @@ -121,7 +121,6 @@ public int getHardCap() { throw new NotImplementedException("Not supported by HTTP storage"); } - @Override public @NotNull naksha.model.DataEncoding getDataEncoding(@Nullable Object feature, @Nullable Object context) { throw new NotImplementedException("Not supported by HTTP storage"); } diff --git a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageProperties.java b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageProperties.java index 7a993d2184..a712d19f53 100644 --- a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageProperties.java +++ b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageProperties.java @@ -116,7 +116,7 @@ public void setMaxRetries(final @Nullable Integer maxRetries) { * By default: 'Content-Type: application/json' and 'Accept-Encoding: gzip' */ public @NotNull Map getHeaders() { - final Object raw = get(HEADERS); + final Object raw = getPath(HEADERS); if (raw instanceof HeaderMap) { return (HeaderMap) raw; } diff --git a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageReadSession.java b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageReadSession.java index 8429edc67d..f79f11d109 100644 --- a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageReadSession.java +++ b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/HttpStorageReadSession.java @@ -22,16 +22,14 @@ import com.here.naksha.storage.http.connector.ConnectorInterfaceReadExecute; import com.here.naksha.storage.http.ffw.FfwInterfaceReadExecute; import naksha.model.IReadSession; +import naksha.model.MemberProcessorMap; import naksha.model.IStorage; import naksha.model.NakshaContext; import naksha.model.NakshaError; import naksha.model.SessionOptions; import naksha.model.objects.NakshaCollection; -import naksha.model.objects.NakshaMap; -import naksha.model.request.ErrorResponse; -import naksha.model.request.FeatureTuple; -import naksha.model.request.Request; -import naksha.model.request.Response; +import naksha.model.objects.NakshaCatalog; +import naksha.model.request.*; import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -129,29 +127,28 @@ public Response executeParallel(@NotNull Request request) { return execute(request); } - @Override public @NotNull IStorage getStorage() { throw new NotImplementedException("Not supported by HTTP storage"); } @Override - public @Nullable NakshaMap getMapById(@NotNull String mapId) { + public @Nullable NakshaCatalog getCatalogById(@NotNull String catalogId) { throw new NotImplementedException("Not supported by HTTP storage"); } @Override - public @Nullable NakshaMap getMapByNumber(int mapNumber) { + public @Nullable NakshaCatalog getCatalogByNumber(int catalogNumber) { return null; } @Override - public void loadTuples(@NotNull List featureTuples, int from, int to, int mode) { + public void loadTuples(@NotNull List featureTuples, int from, int to) { throw new NotImplementedException("Not supported by HTTP storage"); } @Override - public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaMap map, int collectionNumber) { + public @Nullable NakshaCollection getCollectionByNumber(@NotNull NakshaCatalog catalog, int collectionNumber) { throw new NotImplementedException("Not supported by HTTP storage"); } @@ -161,14 +158,14 @@ public void loadTuples(@NotNull List featureTuples, int } @Override - public @Nullable NakshaCollection getCollectionById(@NotNull NakshaMap map, @NotNull String collectionId) { - // TODO: Technically, this translates into creating an ReadCollections query! + public @NotNull MemberProcessorMap getProcessors() { throw new NotImplementedException("Not supported by HTTP storage"); } @Override - public void loadTuples(@NotNull List featureTuples) { - loadTuples(featureTuples, 0, featureTuples.size(), FETCH_ALL); + public @Nullable NakshaCollection getCollectionById(@NotNull NakshaCatalog map, @NotNull String collectionId) { + // TODO: Technically, this translates into creating an ReadCollections query! + throw new NotImplementedException("Not supported by HTTP storage"); } @NotNull diff --git a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/ConnectorInterfaceReadExecute.java b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/ConnectorInterfaceReadExecute.java index 62d317aa68..2064e55349 100644 --- a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/ConnectorInterfaceReadExecute.java +++ b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/ConnectorInterfaceReadExecute.java @@ -167,17 +167,12 @@ private static Event createFeaturesByTileEvent(ReadFeaturesProxyWrapper readRequ } } - private static String firstCollectionIdOrThrow(ReadFeaturesProxyWrapper request) { - StringList ids = request.getCollectionIds(); - if (ids == null || ids.isEmpty()) { - throw new NakshaException(NakshaError.ILLEGAL_ARGUMENT, - "collectionIds must contain at least one non-empty id"); - } - String id0 = ids.get(0); - if (id0 == null || id0.isBlank()) { - throw new NakshaException(NakshaError.ILLEGAL_ARGUMENT, - "First collectionId must be non-empty"); - } - return id0; + private static String firstCollectionIdOrThrow(ReadFeaturesProxyWrapper request) { + String id = request.getCollectionId(); + if (id == null || id.isBlank()) { + throw new NakshaException(NakshaError.ILLEGAL_ARGUMENT, + "collectionId must be non-empty"); } + return id; + } } \ No newline at end of file diff --git a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/pop/IPropertyQueryToPropertiesQuery.java b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/pop/IPropertyQueryToPropertiesQuery.java index 3a8ff25155..dfe304634d 100644 --- a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/pop/IPropertyQueryToPropertiesQuery.java +++ b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/connector/pop/IPropertyQueryToPropertiesQuery.java @@ -98,7 +98,7 @@ private static PropertyQuery simpleLeaf(@NotNull PQuery leaf) { AnyOp anyOp = leaf.getOp(); Object value = leaf.getValue(); - String key = String.join(".", leaf.getProperty().getPath().asList()); + String key = leaf.getProperty().getPath().stream().map(Object::toString).collect(java.util.stream.Collectors.joining(".")); String op = normalizeOp(anyOp); switch (op) { diff --git a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/ffw/FfwInterfaceReadExecute.java b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/ffw/FfwInterfaceReadExecute.java index 7233d6bd79..6a4c0a6f45 100644 --- a/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/ffw/FfwInterfaceReadExecute.java +++ b/here-naksha-storage-http/src/jvmMain/java/com/here/naksha/storage/http/ffw/FfwInterfaceReadExecute.java @@ -179,6 +179,6 @@ private static String keysToKeyValuesStrings(ReadFeaturesProxyWrapper readReques } private static String baseEndpoint(ReadFeaturesProxyWrapper request) { - return request.getCollectionIds().get(0); + return request.getCollectionId(); } } diff --git a/here-naksha-storage-http/src/jvmTest/java/com/here/naksha/storage/http/HttpStoragePropertiesTest.java b/here-naksha-storage-http/src/jvmTest/java/com/here/naksha/storage/http/HttpStoragePropertiesTest.java index 138b17a26a..ca220620a6 100644 --- a/here-naksha-storage-http/src/jvmTest/java/com/here/naksha/storage/http/HttpStoragePropertiesTest.java +++ b/here-naksha-storage-http/src/jvmTest/java/com/here/naksha/storage/http/HttpStoragePropertiesTest.java @@ -127,7 +127,7 @@ void shouldPreserveHeadersWhenBoxingStoragePropertiesFromJson() { final HttpStorageProperties properties = JvmBoxingUtil.box(storage.getProperties(), HttpStorageProperties.class); assertNotNull(properties); - final Object rawHeaders = properties.get("headers"); + final Object rawHeaders = properties.getPath("headers"); assertInstanceOf(Map.class, rawHeaders); assertFalse(rawHeaders instanceof HttpStorageProperties.HeaderMap);