From 7d45780d727c19dfa4a32ec5ce23702992e3dab1 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Fri, 21 Aug 2026 23:49:40 +0530 Subject: [PATCH 1/3] feat(oci): add NoSQL Database Cloud Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements OCI NoSQL Database against the portable database driver: providers/oci/nosql holds the mock over memstore, server/oci/nosql the /20190828 wire handler. Tables are created from a DDL statement rather than a key list, so the provider parses CREATE TABLE and ALTER TABLE — scalar and JSON column types, NOT NULL, DEFAULT, PRIMARY KEY with an optional SHARD, USING TTL in days, and ADD/DROP on a non-key column. Everything else is refused with the construct named: primary keys wider than the portable partition/sort key pair, composite shard keys, structured column types, generated and MR_COUNTER modifiers, TTL in hours, MODIFY and schema freezing, and JSON-path index keys. Tables carry an OCID, a compartment recorded at create and capacity limits validated against their mode; both list routes require compartmentId. Table and index mutations record a work request and stamp opc-work-request-id. Rows are addressed by typed primary key columns and written synchronously. The query endpoint runs SELECT * and DELETE FROM with AND-ed equality conditions — the REST API has no MultiDelete, so DELETE FROM ... WHERE is the multi-row delete. Table usage and the prepared-statement endpoints answer 501 naming the gap. OCI NoSQL publishes no change stream, so the portable stream operations report Unimplemented rather than an empty iterator. The OCI-only surface is a consumer-side Extras interface in server/oci/nosql with its value types in providers/oci/nosql; nothing is added to services/database/driver. Closes #412 --- docs/coverage/coverage.json | 3 +- docs/coverage/oci/README.md | 1 + docs/coverage/oci/nosql.md | 49 ++ docs/services.md | 61 +- providers/oci/nosql/ddl.go | 617 ++++++++++++++++++ providers/oci/nosql/ddl_test.go | 242 ++++++++ providers/oci/nosql/index_extras.go | 121 ++++ providers/oci/nosql/indexes.go | 274 ++++++++ providers/oci/nosql/nosql.go | 486 +++++++++++++++ providers/oci/nosql/nosql_test.go | 931 ++++++++++++++++++++++++++++ providers/oci/nosql/query.go | 304 +++++++++ providers/oci/nosql/race_test.go | 180 ++++++ providers/oci/nosql/row_extras.go | 296 +++++++++ providers/oci/nosql/rows.go | 424 +++++++++++++ providers/oci/nosql/table_extras.go | 280 +++++++++ providers/oci/oci.go | 2 + providers/oci/oci_test.go | 6 + server/oci/nosql/handler.go | 311 ++++++++++ server/oci/nosql/handler_test.go | 739 ++++++++++++++++++++++ server/oci/nosql/index.go | 141 +++++ server/oci/nosql/query.go | 62 ++ server/oci/nosql/row.go | 142 +++++ server/oci/nosql/table.go | 205 ++++++ server/oci/nosql/types.go | 196 ++++++ server/oci/oci.go | 5 + server/oci/oci_test.go | 31 + 26 files changed, 6107 insertions(+), 2 deletions(-) create mode 100644 docs/coverage/oci/nosql.md create mode 100644 providers/oci/nosql/ddl.go create mode 100644 providers/oci/nosql/ddl_test.go create mode 100644 providers/oci/nosql/index_extras.go create mode 100644 providers/oci/nosql/indexes.go create mode 100644 providers/oci/nosql/nosql.go create mode 100644 providers/oci/nosql/nosql_test.go create mode 100644 providers/oci/nosql/query.go create mode 100644 providers/oci/nosql/race_test.go create mode 100644 providers/oci/nosql/row_extras.go create mode 100644 providers/oci/nosql/rows.go create mode 100644 providers/oci/nosql/table_extras.go create mode 100644 server/oci/nosql/handler.go create mode 100644 server/oci/nosql/handler_test.go create mode 100644 server/oci/nosql/index.go create mode 100644 server/oci/nosql/query.go create mode 100644 server/oci/nosql/row.go create mode 100644 server/oci/nosql/table.go create mode 100644 server/oci/nosql/types.go diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 3cf0f52b7..fda7e26b0 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -4700,7 +4700,8 @@ "providers": { "aws": "DynamoDB", "azure": "CosmosDB", - "gcp": "Firestore" + "gcp": "Firestore", + "oci": "NoSQL" } }, { diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index a04d3407d..80aa53575 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -7,5 +7,6 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider | --- | --- | --- | | [Identity](./identity.md) | `iam` | 40 | | [Monitoring](./monitoring.md) | `monitoring` | 12 | +| [NoSQL](./nosql.md) | `database` | 24 | | [VCN](./vcn.md) | `networking` | 57 | | [Workrequest](./workrequest.md) | — (provider-native) | 4 | diff --git a/docs/coverage/oci/nosql.md b/docs/coverage/oci/nosql.md new file mode 100644 index 000000000..19ec44cdf --- /dev/null +++ b/docs/coverage/oci/nosql.md @@ -0,0 +1,49 @@ + +# NoSQL + +OCI's `database` service · portable interface `driver.Database` · [OCI index](./README.md) + +## Operations (24) + +| Operation | Description | +| --- | --- | +| `BatchGetItems` | | +| `BatchPutItems` | | +| `CreateIndex` | Global Secondary Indexes | +| `CreateTable` | | +| `DeleteIndex` | | +| `DeleteItem` | | +| `DeleteTable` | | +| `DescribeIndex` | | +| `DescribeTTL` | | +| `DescribeTable` | | +| `GetItem` | | +| `GetStreamRecords` | | +| `ListIndexes` | | +| `ListTables` | | +| `ListTagsOfResource` | | +| `PutItem` | | +| `Query` | | +| `Scan` | | +| `TagResource` | Tagging | +| `TransactWriteItems` | Transactional writes | +| `UntagResource` | | +| `UpdateItem` | | +| `UpdateStreamConfig` | Streams / Change Feed | +| `UpdateTTL` | TTL | + +## Optional capabilities + +Discovered by type assertion; only some providers implement these. + +### TableAttributes + +TableAttributes is an OPTIONAL capability, discovered by type assertion (like + +| Operation | Description | +| --- | --- | +| `TableAttributes` | | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/services.md b/docs/services.md index da13153e8..d3e1d3446 100644 --- a/docs/services.md +++ b/docs/services.md @@ -283,7 +283,7 @@ client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{IncludeManagedResource ## 3. Database **Driver interface:** `services/database/driver/driver.go` -**AWS:** DynamoDB | **Azure:** Cosmos DB | **GCP:** Firestore +**AWS:** DynamoDB | **Azure:** Cosmos DB | **GCP:** Firestore | **OCI:** NoSQL Database ### Table Operations @@ -343,6 +343,65 @@ client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{IncludeManagedResource **Total: 21 operations** +### OCI NoSQL Database + +**Optional capability:** `server/oci/nosql.Extras` — OCI creates a table from a +DDL statement rather than a key list, gives it an OCID, a compartment and +capacity limits, and addresses rows by typed primary key columns, none of which +the portable model carries. Its value types live in `providers/oci/nosql`. +**Provider:** `providers/oci/nosql` | **Wire:** `server/oci/nosql` + +| Operation | Route | +|-----------|-------| +| `CreateTable` | `POST /20190828/tables` | +| `ListTables` | `GET /20190828/tables` | +| `GetTable` | `GET /20190828/tables/{tableNameOrId}` | +| `UpdateTable` | `PUT /20190828/tables/{tableNameOrId}` | +| `DeleteTable` | `DELETE /20190828/tables/{tableNameOrId}` | +| `ChangeTableCompartment` | `POST /20190828/tables/{tableNameOrId}/actions/changeCompartment` | +| `CreateIndex` | `POST /20190828/tables/{tableNameOrId}/indexes` | +| `ListIndexes` | `GET /20190828/tables/{tableNameOrId}/indexes` | +| `GetIndex` | `GET /20190828/tables/{tableNameOrId}/indexes/{indexName}` | +| `DeleteIndex` | `DELETE /20190828/tables/{tableNameOrId}/indexes/{indexName}` | +| `GetRow` | `GET /20190828/tables/{tableNameOrId}/rows` | +| `UpdateRow` | `PUT /20190828/tables/{tableNameOrId}/rows` | +| `DeleteRow` | `DELETE /20190828/tables/{tableNameOrId}/rows` | +| `Query` | `POST /20190828/query` | + +A table is addressed by name or OCID. Both list routes require `compartmentId` +and paginate with `limit` / `page`, returning the cursor as `opc-next-page`; +real OCI marks it optional on `ListIndexes`, and CloudEmu requires it so every +list is compartment-scoped. Table and index mutations are asynchronous in real +OCI, so they answer `202` with an `opc-work-request-id`; row writes are +synchronous and answer `200`. + +The DDL is parsed, not stored and ignored. `CreateTable` reads `CREATE TABLE` +and `UpdateTable` reads `ALTER TABLE` — scalar and `JSON` column types, `NOT +NULL`, `DEFAULT`, `PRIMARY KEY` with an optional `SHARD`, `USING TTL DAYS`, +and `ADD` / `DROP` on a non-key column. Everything else is refused with the +construct named: primary keys of more than two columns and composite shard +keys, which the portable partition/sort key pair cannot identify a row by; +`ARRAY`, `MAP`, `RECORD` and `ENUM` columns; generated, `MR_COUNTER` and +`UUID` modifiers; `USING TTL` in `HOURS`, which OCI's own `Schema` model +reports only in days; `MODIFY` and schema freezing; and JSON-path index keys. + +`Query` runs `SELECT *` and `DELETE FROM` over one table with AND-ed equality +conditions — the REST API has no `MultiDelete`, so `DELETE FROM … WHERE` is how +several rows go at once. Column projections, aggregates, joins, `ORDER BY` and +range conditions are rejected rather than silently reinterpreted. + +`/tables/{id}/usage` answers `501`: CloudEmu does not meter read, write and +storage consumption, so a row of zeros would read as real telemetry. So do +`/query/prepare` and `/query/summarize`, which hand back a prepared-statement +handle there is nothing to bind. **OCI NoSQL publishes no change stream** — it +has no DynamoDB-Streams or Cosmos-change-feed equivalent — so +`UpdateStreamConfig` and `GetStreamRecords` report `Unimplemented` rather than +returning an empty iterator that would read as "no changes yet". + +Tables report `ACTIVE` from creation: every CloudEmu mutation is synchronous, +so the `CREATING` and `DELETING` states an SDK waiter may poll for are never +observable. + --- ## 4. Serverless diff --git a/providers/oci/nosql/ddl.go b/providers/oci/nosql/ddl.go new file mode 100644 index 000000000..03a61a2e0 --- /dev/null +++ b/providers/oci/nosql/ddl.go @@ -0,0 +1,617 @@ +package nosql + +import ( + "regexp" + "strconv" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// DDL statement kinds the parser recognizes. +const ( + DDLCreateTable = "CREATE TABLE" + DDLAlterTable = "ALTER TABLE" +) + +// maxPrimaryKeyColumns is what the portable partition/sort key pair holds. A +// third column would make two distinct rows share one identity, so the parser +// refuses rather than silently collapsing them. +const maxPrimaryKeyColumns = 2 + +// Column types the mock stores values for. +const ( + typeInteger = "INTEGER" + typeLong = "LONG" + typeFloat = "FLOAT" + typeDouble = "DOUBLE" + typeNumber = "NUMBER" + typeString = "STRING" + typeBoolean = "BOOLEAN" + typeBinary = "BINARY" + typeTimestamp = "TIMESTAMP" + typeJSON = "JSON" +) + +// columnTypes are the scalar OCI types the mock stores. The structured types +// (ARRAY, MAP, RECORD, ENUM) and generated columns are rejected by name. +// +//nolint:gochecknoglobals // a lookup table, read-only after init. +var columnTypes = map[string]bool{ + typeInteger: true, typeLong: true, typeFloat: true, typeDouble: true, + typeNumber: true, typeString: true, typeBoolean: true, typeBinary: true, + typeTimestamp: true, typeJSON: true, +} + +// identifierRE is an OCI NoSQL table, column or index name. The leading +// letter is what keeps a declared column from colliding with ttlExpiryColumn. +var identifierRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + +// AlterSpec is what an ALTER TABLE statement changes. +type AlterSpec struct { + AddColumns []Column + DropColumns []string + TTL *TTL +} + +// IndexSpec describes an index to build. +type IndexSpec struct { + Name string + Table string + Columns []string +} + +// DDL is a parsed statement. Only the fields its Kind uses are populated. +type DDL struct { + Kind string + Table string + IfNotExists bool + Schema Schema + Alter AlterSpec +} + +// ParseDDL parses the statement OCI's CreateTable and UpdateTable take. It +// rejects, by name, anything it does not model rather than accepting a +// statement it would then ignore. +func ParseDDL(statement string) (*DDL, error) { + stmt := normaliseStatement(statement) + if stmt == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "ddlStatement is required") + } + + upper := strings.ToUpper(stmt) + + switch { + case strings.HasPrefix(upper, DDLCreateTable): + return parseCreateTable(stmt) + case strings.HasPrefix(upper, DDLAlterTable): + return parseAlterTable(stmt) + } + + return nil, cerrors.Newf(cerrors.InvalidArgument, + "unsupported DDL statement %q; CloudEmu parses CREATE TABLE and ALTER TABLE. Indexes are built "+ + "through the indexes endpoint and a table is dropped through DeleteTable", leadingWords(stmt)) +} + +// normaliseStatement collapses whitespace and drops a trailing semicolon. +func normaliseStatement(statement string) string { + return strings.TrimSuffix(strings.Join(strings.Fields(statement), " "), ";") +} + +// leadingWords names the statement a rejection is about, without echoing a +// whole multi-line body back at the caller. +func leadingWords(stmt string) string { + words := strings.Fields(stmt) + if len(words) > 2 { //nolint:mnd // the verb and its object are enough to name it + words = words[:2] + } + + return strings.Join(words, " ") +} + +// parseCreateTable parses +// CREATE TABLE [IF NOT EXISTS] name (columns, PRIMARY KEY (...)) [USING TTL n DAYS]. +func parseCreateTable(stmt string) (*DDL, error) { + rest := stmt[len(DDLCreateTable):] + + d := &DDL{Kind: DDLCreateTable} + rest, d.IfNotExists = cutKeyword(rest, "IF NOT EXISTS") + + head, body, tail, err := splitParenthesised(rest) + if err != nil { + return nil, err + } + + if d.Table, err = requireIdentifier(head, "table name"); err != nil { + return nil, err + } + + if d.Schema, err = parseSchema(body); err != nil { + return nil, err + } + + ttl, err := parseTTLClause(tail) + if err != nil { + return nil, err + } + + if ttl != nil { + d.Schema.TTL = *ttl + } + + return d, nil +} + +// parseSchema reads the column list and the PRIMARY KEY declaration. +func parseSchema(body string) (Schema, error) { + fields := splitTopLevel(body) + if len(fields) == 0 { + return Schema{}, cerrors.New(cerrors.InvalidArgument, "CREATE TABLE declares no columns") + } + + var ( + s Schema + keyed bool + keyErr error + ) + + for _, f := range fields { + if strings.HasPrefix(strings.ToUpper(f), "PRIMARY KEY") { + if keyed { + return Schema{}, cerrors.New(cerrors.InvalidArgument, "CREATE TABLE declares PRIMARY KEY twice") + } + + keyed = true + + if s.ShardKey, s.PrimaryKey, keyErr = parsePrimaryKey(f); keyErr != nil { + return Schema{}, keyErr + } + + continue + } + + col, err := parseColumn(f) + if err != nil { + return Schema{}, err + } + + s.Columns = append(s.Columns, col) + } + + if !keyed { + return Schema{}, cerrors.New(cerrors.InvalidArgument, "CREATE TABLE declares no PRIMARY KEY") + } + + if err := validateSchema(&s); err != nil { + return Schema{}, err + } + + return s, nil +} + +// validateSchema checks that every key column is declared and that the +// primary key fits the portable partition/sort key pair. +func validateSchema(s *Schema) error { + declared := map[string]bool{} + for _, c := range s.Columns { + declared[c.Name] = true + } + + for _, k := range s.PrimaryKey { + if !declared[k] { + return cerrors.Newf(cerrors.InvalidArgument, "primary key column %q is not declared", k) + } + } + + if len(s.PrimaryKey) > maxPrimaryKeyColumns { + return cerrors.Newf(cerrors.InvalidArgument, + "primary keys of more than %d columns are not supported; CloudEmu maps the OCI primary key "+ + "onto the portable partition and sort key pair", maxPrimaryKeyColumns) + } + + if len(s.ShardKey) != 1 { + return cerrors.New(cerrors.InvalidArgument, + "composite shard keys are not supported; declare SHARD over exactly one column") + } + + if s.ShardKey[0] != s.PrimaryKey[0] { + return cerrors.New(cerrors.InvalidArgument, "the shard key must be the leading primary key column") + } + + return nil +} + +// parsePrimaryKey reads PRIMARY KEY (SHARD(a), b) or PRIMARY KEY (a, b), +// returning the shard columns and the full key in declaration order. +func parsePrimaryKey(field string) (shard, full []string, err error) { + inner, err := insideParens(field, "PRIMARY KEY") + if err != nil { + return nil, nil, err + } + + for _, part := range splitTopLevel(inner) { + if !strings.HasPrefix(strings.ToUpper(part), "SHARD") { + name, idErr := requireIdentifier(part, "primary key column") + if idErr != nil { + return nil, nil, idErr + } + + full = append(full, name) + + continue + } + + if len(full) > 0 { + return nil, nil, cerrors.New(cerrors.InvalidArgument, "SHARD must lead the PRIMARY KEY") + } + + columns, sErr := parseShardGroup(part) + if sErr != nil { + return nil, nil, sErr + } + + shard = append(shard, columns...) + full = append(full, columns...) + } + + if len(full) == 0 { + return nil, nil, cerrors.New(cerrors.InvalidArgument, "PRIMARY KEY names no columns") + } + + // Without an explicit SHARD, OCI shards on the leading primary key column. + if len(shard) == 0 { + shard = []string{full[0]} + } + + return shard, full, nil +} + +// parseShardGroup reads the column list inside SHARD(...). +func parseShardGroup(part string) ([]string, error) { + inner, err := insideParens(part, "SHARD") + if err != nil { + return nil, err + } + + var out []string + + for _, c := range splitTopLevel(inner) { + name, idErr := requireIdentifier(c, "shard key column") + if idErr != nil { + return nil, idErr + } + + out = append(out, name) + } + + return out, nil +} + +// parseColumn reads NAME TYPE [NOT NULL] [DEFAULT value]. +func parseColumn(field string) (Column, error) { + words := strings.Fields(field) + if len(words) < 2 { //nolint:mnd // a column is at least a name and a type + return Column{}, cerrors.Newf(cerrors.InvalidArgument, "column %q declares no type", field) + } + + name, err := requireIdentifier(words[0], "column name") + if err != nil { + return Column{}, err + } + + typeName, err := parseColumnType(words[1]) + if err != nil { + return Column{}, err + } + + col := Column{Name: name, Type: typeName, IsNullable: true} + + if err := parseColumnModifiers(&col, words[2:]); err != nil { + return Column{}, err + } + + return col, nil +} + +// parseColumnType strips a TIMESTAMP precision and rejects the types the mock +// has no storage shape for. +func parseColumnType(word string) (string, error) { + name := strings.ToUpper(word) + if i := strings.IndexByte(name, '('); i >= 0 { + name = name[:i] + } + + if !columnTypes[name] { + return "", cerrors.Newf(cerrors.InvalidArgument, + "column type %q is not supported; CloudEmu stores the scalar types and JSON", name) + } + + return name, nil +} + +// parseColumnModifiers applies NOT NULL and DEFAULT. Anything else — the +// generated, counter and comment modifiers among them — is named and refused. +func parseColumnModifiers(col *Column, words []string) error { + for i := 0; i < len(words); i++ { + switch { + case strings.EqualFold(words[i], "NOT") && i+1 < len(words) && strings.EqualFold(words[i+1], "NULL"): + col.IsNullable = false + i++ + case strings.EqualFold(words[i], "DEFAULT"): + if i+1 >= len(words) { + return cerrors.Newf(cerrors.InvalidArgument, "DEFAULT on column %q names no value", col.Name) + } + + col.DefaultValue = strings.Trim(strings.Join(words[i+1:], " "), `"'`) + + return nil + default: + return cerrors.Newf(cerrors.InvalidArgument, + "unsupported column modifier %q on column %q", words[i], col.Name) + } + } + + return nil +} + +// parseAlterTable parses ALTER TABLE name (ADD col type | DROP col) and +// ALTER TABLE name USING TTL n DAYS. +func parseAlterTable(stmt string) (*DDL, error) { + rest := stmt[len(DDLAlterTable):] + d := &DDL{Kind: DDLAlterTable} + + if i := strings.IndexByte(rest, '('); i < 0 { + return parseAlterTTL(d, rest) + } + + head, body, tail, err := splitParenthesised(rest) + if err != nil { + return nil, err + } + + if strings.TrimSpace(tail) != "" { + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported ALTER TABLE clause %q", strings.TrimSpace(tail)) + } + + if d.Table, err = requireIdentifier(head, "table name"); err != nil { + return nil, err + } + + for _, part := range splitTopLevel(body) { + if err := applyAlterPart(&d.Alter, part); err != nil { + return nil, err + } + } + + return d, nil +} + +// applyAlterPart reads one ADD or DROP inside an ALTER TABLE body. +func applyAlterPart(spec *AlterSpec, part string) error { + upper := strings.ToUpper(part) + + switch { + case strings.HasPrefix(upper, "ADD "): + col, err := parseColumn(strings.TrimSpace(part[len("ADD "):])) + if err != nil { + return err + } + + spec.AddColumns = append(spec.AddColumns, col) + + return nil + case strings.HasPrefix(upper, "DROP "): + name, err := requireIdentifier(part[len("DROP "):], "column name") + if err != nil { + return err + } + + spec.DropColumns = append(spec.DropColumns, name) + + return nil + } + + return cerrors.Newf(cerrors.InvalidArgument, + "unsupported ALTER TABLE action %q; CloudEmu applies ADD, DROP and USING TTL", leadingWords(part)) +} + +// parseAlterTTL reads the parenthesis-free forms of ALTER TABLE. +func parseAlterTTL(d *DDL, rest string) (*DDL, error) { + words := strings.Fields(rest) + if len(words) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "ALTER TABLE names no table") + } + + name, err := requireIdentifier(words[0], "table name") + if err != nil { + return nil, err + } + + d.Table = name + + clause := strings.Join(words[1:], " ") + if !strings.HasPrefix(strings.ToUpper(clause), "USING ") { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "unsupported ALTER TABLE clause %q; CloudEmu applies ADD, DROP and USING TTL", clause) + } + + ttl, err := parseTTLClause(clause) + if err != nil { + return nil, err + } + + d.Alter.TTL = ttl + + return d, nil +} + +// parseTTLClause reads a trailing USING TTL n DAYS|HOURS. An empty clause +// returns nil; anything else trailing is an error, so a clause the mock does +// not model is never accepted and ignored. +func parseTTLClause(clause string) (*TTL, error) { + words := strings.Fields(clause) + if len(words) == 0 { + return nil, nil //nolint:nilnil // no clause is not an error + } + + const ttlWords = 4 // USING TTL n UNIT + + if len(words) != ttlWords || !strings.EqualFold(words[0], "USING") || !strings.EqualFold(words[1], "TTL") { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "unsupported clause %q; CloudEmu applies USING TTL DAYS", clause) + } + + n, err := strconv.Atoi(words[2]) + if err != nil || n < 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "TTL value %q is not a non-negative integer", words[2]) + } + + if !strings.EqualFold(words[3], "DAYS") { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "TTL unit %q is not supported; OCI reports a table's TTL in DAYS", words[3]) + } + + return &TTL{Days: n}, nil +} + +// cutKeyword strips a leading keyword phrase, reporting whether it was there. +func cutKeyword(s, keyword string) (rest string, found bool) { + trimmed := strings.TrimSpace(s) + if len(trimmed) < len(keyword) || !strings.EqualFold(trimmed[:len(keyword)], keyword) { + return s, false + } + + return trimmed[len(keyword):], true +} + +// splitParenthesised splits "head ( body ) tail" on the outermost pair. +func splitParenthesised(s string) (head, body, tail string, err error) { + open := strings.IndexByte(s, '(') + if open < 0 { + return "", "", "", cerrors.Newf(cerrors.InvalidArgument, "statement %q has no parenthesised body", leadingWords(s)) + } + + depth := 0 + + for i := open; i < len(s); i++ { + switch s[i] { + case '(': + depth++ + case ')': + depth-- + + if depth == 0 { + return s[:open], s[open+1 : i], s[i+1:], nil + } + } + } + + return "", "", "", cerrors.New(cerrors.InvalidArgument, "unbalanced parentheses in DDL statement") +} + +// splitTopLevel splits a comma list, ignoring commas nested in parentheses. +func splitTopLevel(s string) []string { + var ( + out []string + depth int + start int + ) + + for i := 0; i < len(s); i++ { + switch s[i] { + case '(': + depth++ + case ')': + depth-- + case ',': + if depth == 0 { + out = appendField(out, s[start:i]) + start = i + 1 + } + } + } + + return appendField(out, s[start:]) +} + +func appendField(out []string, field string) []string { + if f := strings.TrimSpace(field); f != "" { + out = append(out, f) + } + + return out +} + +// insideParens returns the body of "keyword ( ... )". +func insideParens(s, keyword string) (string, error) { + _, body, tail, err := splitParenthesised(s) + if err != nil { + return "", err + } + + if strings.TrimSpace(tail) != "" { + return "", cerrors.Newf(cerrors.InvalidArgument, "unexpected %q after %s", strings.TrimSpace(tail), keyword) + } + + return body, nil +} + +// requireIdentifier validates a table, column or index name. +func requireIdentifier(s, what string) (string, error) { + name := strings.TrimSpace(s) + if !identifierRE.MatchString(name) { + return "", cerrors.Newf(cerrors.InvalidArgument, "%s %q is not a valid identifier", what, name) + } + + return name, nil +} + +// schemaFromConfig derives the OCI schema a portable table config implies. +// The portable shape declares no types, so every column is a STRING. +func schemaFromConfig(cfg *driver.TableConfig) Schema { + s := Schema{ + Columns: []Column{{Name: cfg.PartitionKey, Type: typeString}}, + PrimaryKey: []string{cfg.PartitionKey}, + ShardKey: []string{cfg.PartitionKey}, + } + + if cfg.SortKey != "" { + s.Columns = append(s.Columns, Column{Name: cfg.SortKey, Type: typeString}) + s.PrimaryKey = append(s.PrimaryKey, cfg.SortKey) + } + + return s +} + +// ddlFromSchema renders the CREATE TABLE statement a schema corresponds to, +// so a table created through the portable API still reports one. +func ddlFromSchema(name string, s *Schema) string { + cols := make([]string, 0, len(s.Columns)) + for _, c := range s.Columns { + cols = append(cols, c.Name+" "+c.Type) + } + + key := "SHARD(" + strings.Join(s.ShardKey, ", ") + ")" + if sk := sortKeyOf(s); sk != "" { + key += ", " + sk + } + + stmt := "CREATE TABLE " + name + " (" + strings.Join(cols, ", ") + ", PRIMARY KEY (" + key + "))" + + if s.TTL.Days > 0 { + stmt += " USING TTL " + strconv.Itoa(s.TTL.Days) + " DAYS" + } + + return stmt +} + +// indexFromGSI projects a portable index config onto the OCI shape. +func indexFromGSI(cfg *driver.GSIConfig) IndexSpec { + spec := IndexSpec{Name: cfg.Name, Columns: []string{cfg.PartitionKey}} + if cfg.SortKey != "" { + spec.Columns = append(spec.Columns, cfg.SortKey) + } + + return spec +} diff --git a/providers/oci/nosql/ddl_test.go b/providers/oci/nosql/ddl_test.go new file mode 100644 index 000000000..7f2483854 --- /dev/null +++ b/providers/oci/nosql/ddl_test.go @@ -0,0 +1,242 @@ +package nosql_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/nosql" +) + +func TestParseCreateTable(t *testing.T) { + tests := []struct { + name string + statement string + expectTable string + expectShard []string + expectKey []string + expectCols int + expectTTL int + }{ + { + name: "explicit shard and sort key", + statement: "CREATE TABLE users (id INTEGER, email STRING, PRIMARY KEY (SHARD(id), email))", + expectTable: "users", + expectShard: []string{"id"}, + expectKey: []string{"id", "email"}, + expectCols: 2, + }, + { + name: "implicit shard key is the leading primary key column", + statement: "CREATE TABLE orders (sku STRING, qty INTEGER, PRIMARY KEY (sku))", + expectTable: "orders", + expectShard: []string{"sku"}, + expectKey: []string{"sku"}, + expectCols: 2, + }, + { + name: "if not exists", + statement: "CREATE TABLE IF NOT EXISTS t (a STRING, PRIMARY KEY (a))", + expectTable: "t", + expectShard: []string{"a"}, + expectKey: []string{"a"}, + expectCols: 1, + }, + { + name: "multi-line with ttl, modifiers and json", + statement: `CREATE TABLE stream ( + id LONG, + payload JSON, + label STRING NOT NULL DEFAULT 'none', + ts TIMESTAMP(3), + PRIMARY KEY (SHARD(id)) + ) USING TTL 7 DAYS`, + expectTable: "stream", + expectShard: []string{"id"}, + expectKey: []string{"id"}, + expectCols: 4, + expectTTL: 7, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d, err := nosql.ParseDDL(tc.statement) + require.NoError(t, err) + + assert.Equal(t, nosql.DDLCreateTable, d.Kind) + assert.Equal(t, tc.expectTable, d.Table) + assert.Equal(t, tc.expectShard, d.Schema.ShardKey) + assert.Equal(t, tc.expectKey, d.Schema.PrimaryKey) + assert.Len(t, d.Schema.Columns, tc.expectCols) + assert.Equal(t, tc.expectTTL, d.Schema.TTL.Days) + }) + } +} + +func TestParseCreateTableColumnModifiers(t *testing.T) { + d, err := nosql.ParseDDL( + "CREATE TABLE t (a STRING, b STRING NOT NULL, c STRING DEFAULT 'hi', PRIMARY KEY (a))") + require.NoError(t, err) + + require.Len(t, d.Schema.Columns, 3) + assert.True(t, d.Schema.Columns[0].IsNullable) + assert.False(t, d.Schema.Columns[1].IsNullable) + assert.Equal(t, "hi", d.Schema.Columns[2].DefaultValue) +} + +// TestParseDDLRejectsUnsupported is the contract that keeps the parser from +// accepting a statement it would then ignore: every unsupported construct is +// refused, and the message names what was refused. +func TestParseDDLRejectsUnsupported(t *testing.T) { + tests := []struct { + name string + statement string + expectWord string + }{ + { + name: "empty statement", + statement: " ", + expectWord: "ddlStatement is required", + }, + { + name: "unknown verb", + statement: "TRUNCATE TABLE users", + expectWord: "TRUNCATE TABLE", + }, + { + name: "drop table is not a ddl statement here", + statement: "DROP TABLE users", + expectWord: "DROP TABLE", + }, + { + name: "create index goes through the indexes endpoint", + statement: "CREATE INDEX i ON users (email)", + expectWord: "CREATE INDEX", + }, + { + name: "three column primary key", + statement: "CREATE TABLE t (a STRING, b STRING, c STRING, PRIMARY KEY (SHARD(a), b, c))", + expectWord: "more than 2 columns", + }, + { + name: "composite shard key", + statement: "CREATE TABLE t (a STRING, b STRING, PRIMARY KEY (SHARD(a, b)))", + expectWord: "composite shard keys", + }, + { + name: "undeclared primary key column", + statement: "CREATE TABLE t (a STRING, PRIMARY KEY (z))", + expectWord: `"z" is not declared`, + }, + { + name: "no primary key", + statement: "CREATE TABLE t (a STRING)", + expectWord: "no PRIMARY KEY", + }, + { + name: "structured column type", + statement: "CREATE TABLE t (a STRING, b ARRAY(STRING), PRIMARY KEY (a))", + expectWord: `"ARRAY" is not supported`, + }, + { + name: "record column type", + statement: "CREATE TABLE t (a STRING, b RECORD(x STRING), PRIMARY KEY (a))", + expectWord: `"RECORD" is not supported`, + }, + { + name: "generated identity column", + statement: "CREATE TABLE t (a INTEGER GENERATED ALWAYS AS IDENTITY, PRIMARY KEY (a))", + expectWord: "unsupported column modifier", + }, + { + name: "mr_counter column", + statement: "CREATE TABLE t (a STRING, b INTEGER AS MR_COUNTER, PRIMARY KEY (a))", + expectWord: "unsupported column modifier", + }, + { + name: "ttl in hours", + statement: "CREATE TABLE t (a STRING, PRIMARY KEY (a)) USING TTL 6 HOURS", + expectWord: "TTL in DAYS", + }, + { + name: "unknown trailing clause", + statement: "CREATE TABLE t (a STRING, PRIMARY KEY (a)) WITH SCHEMA FROZEN", + expectWord: "unsupported clause", + }, + { + name: "child table name", + statement: "CREATE TABLE parent.child (a STRING, PRIMARY KEY (a))", + expectWord: "is not a valid identifier", + }, + { + name: "unbalanced parentheses", + statement: "CREATE TABLE t (a STRING, PRIMARY KEY (a)", + expectWord: "unbalanced parentheses", + }, + { + name: "alter with modify", + statement: "ALTER TABLE t (MODIFY a STRING)", + expectWord: "unsupported ALTER TABLE action", + }, + { + name: "alter freeze schema", + statement: "ALTER TABLE t FREEZE SCHEMA", + expectWord: "unsupported ALTER TABLE clause", + }, + { + name: "shard not leading the primary key", + statement: "CREATE TABLE t (a STRING, b STRING, PRIMARY KEY (a, SHARD(b)))", + expectWord: "SHARD must lead", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := nosql.ParseDDL(tc.statement) + + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.expectWord) + }) + } +} + +func TestParseAlterTable(t *testing.T) { + tests := []struct { + name string + statement string + expectAdd int + expectDrop int + expectTTL int + }{ + {name: "add column", statement: "ALTER TABLE t (ADD nickname STRING)", expectAdd: 1}, + {name: "drop column", statement: "ALTER TABLE t (DROP nickname)", expectDrop: 1}, + { + name: "add and drop together", + statement: "ALTER TABLE t (ADD a STRING, DROP b)", + expectAdd: 1, + expectDrop: 1, + }, + {name: "ttl", statement: "ALTER TABLE t USING TTL 3 DAYS", expectTTL: 3}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d, err := nosql.ParseDDL(tc.statement) + require.NoError(t, err) + + assert.Equal(t, nosql.DDLAlterTable, d.Kind) + assert.Equal(t, "t", d.Table) + assert.Len(t, d.Alter.AddColumns, tc.expectAdd) + assert.Len(t, d.Alter.DropColumns, tc.expectDrop) + + if tc.expectTTL > 0 { + require.NotNil(t, d.Alter.TTL) + assert.Equal(t, tc.expectTTL, d.Alter.TTL.Days) + } + }) + } +} diff --git a/providers/oci/nosql/index_extras.go b/providers/oci/nosql/index_extras.go new file mode 100644 index 000000000..6c0f08ea7 --- /dev/null +++ b/providers/oci/nosql/index_extras.go @@ -0,0 +1,121 @@ +package nosql + +import ( + "context" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// CreateOCIIndex builds a secondary index from OCI's key list. +func (m *Mock) CreateOCIIndex(_ context.Context, nameOrID string, spec IndexSpec, ifNotExists bool) (*Index, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + if existing, findErr := findIndex(t, spec.Name); findErr == nil { + if ifNotExists { + return cloneIndex(existing), nil + } + + return nil, cerrors.Newf(cerrors.AlreadyExists, "index %q already exists on table %q", spec.Name, t.Name) + } + + idx, err := m.addIndex(t, spec) + if err != nil { + return nil, err + } + + return cloneIndex(idx), nil +} + +// GetOCIIndex returns one index on a table. +func (m *Mock) GetOCIIndex(_ context.Context, nameOrID, indexName string) (*Index, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + idx, err := findIndex(t, indexName) + if err != nil { + return nil, err + } + + return cloneIndex(idx), nil +} + +// ListOCIIndexes returns a table's indexes ordered by name. A non-empty +// indexName narrows the listing, as OCI's name query parameter does. +func (m *Mock) ListOCIIndexes(_ context.Context, nameOrID, indexName string) ([]Index, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + out := make([]Index, 0, len(t.Indexes)) + + for _, idx := range t.Indexes { + if indexName != "" && idx.Name != indexName { + continue + } + + out = append(out, *cloneIndex(idx)) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out, nil +} + +// DeleteOCIIndex drops an index. isIfExists makes dropping a missing index a +// no-op, as OCI's query parameter of that name does. +func (m *Mock) DeleteOCIIndex(_ context.Context, nameOrID, indexName string, ifExists bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return err + } + + if err := m.dropIndex(t, indexName); err != nil { + if ifExists && cerrors.GetCode(err) == cerrors.NotFound { + return nil + } + + return err + } + + return nil +} + +// OCITableScope returns the compartment a table lives in, which the handler +// stamps on the work requests it records. +func (m *Mock) OCITableScope(nameOrID string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return "" + } + + return t.Scope.Compartment +} + +func cloneIndex(idx *Index) *Index { + out := *idx + out.Keys = append([]IndexKey(nil), idx.Keys...) + + return &out +} diff --git a/providers/oci/nosql/indexes.go b/providers/oci/nosql/indexes.go new file mode 100644 index 000000000..81a03ad57 --- /dev/null +++ b/providers/oci/nosql/indexes.go @@ -0,0 +1,274 @@ +package nosql + +import ( + "context" + "maps" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// CreateIndex builds a secondary index on a table. +func (m *Mock) CreateIndex(_ context.Context, table string, cfg driver.GSIConfig) (*driver.IndexInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return nil, err + } + + idx, err := m.addIndex(t, indexFromGSI(&cfg)) + if err != nil { + return nil, err + } + + return toIndexInfo(idx), nil +} + +// addIndex records an index on a table. Callers must hold m.mu. +func (m *Mock) addIndex(t *tableData, spec IndexSpec) (*Index, error) { + if spec.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "index name is required") + } + + if len(spec.Columns) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "an index names at least one column") + } + + for _, idx := range t.Indexes { + if idx.Name == spec.Name { + return nil, cerrors.Newf(cerrors.AlreadyExists, "index %q already exists on table %q", spec.Name, t.Name) + } + } + + declared := map[string]bool{} + for _, c := range t.Schema.Columns { + declared[c.Name] = true + } + + idx := &Index{Name: spec.Name, LifecycleState: StateActive} + + for _, c := range spec.Columns { + if !declared[c] { + return nil, cerrors.Newf(cerrors.InvalidArgument, "index column %q is not declared on table %q", c, t.Name) + } + + idx.Keys = append(idx.Keys, IndexKey{ColumnName: c}) + } + + t.Indexes = append(t.Indexes, idx) + t.TimeUpdated = m.now() + + return idx, nil +} + +// DeleteIndex drops a secondary index. +func (m *Mock) DeleteIndex(_ context.Context, table, indexName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return err + } + + return m.dropIndex(t, indexName) +} + +// dropIndex removes an index from a table. Callers must hold m.mu. +func (m *Mock) dropIndex(t *tableData, name string) error { + for i, idx := range t.Indexes { + if idx.Name != name { + continue + } + + t.Indexes = append(t.Indexes[:i], t.Indexes[i+1:]...) + t.TimeUpdated = m.now() + + return nil + } + + return cerrors.Newf(cerrors.NotFound, "index %q not found on table %q", name, t.Name) +} + +// DescribeIndex returns one secondary index. +func (m *Mock) DescribeIndex(_ context.Context, table, indexName string) (*driver.IndexInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.lookup(table) + if err != nil { + return nil, err + } + + idx, err := findIndex(t, indexName) + if err != nil { + return nil, err + } + + return toIndexInfo(idx), nil +} + +// ListIndexes returns every secondary index on a table. +func (m *Mock) ListIndexes(_ context.Context, table string) ([]driver.IndexInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.lookup(table) + if err != nil { + return nil, err + } + + out := make([]driver.IndexInfo, 0, len(t.Indexes)) + for _, idx := range t.Indexes { + out = append(out, *toIndexInfo(idx)) + } + + return out, nil +} + +// findIndex returns a named index. Callers must hold m.mu. +func findIndex(t *tableData, name string) (*Index, error) { + for _, idx := range t.Indexes { + if idx.Name == name { + return idx, nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "index %q not found on table %q", name, t.Name) +} + +func toIndexInfo(idx *Index) *driver.IndexInfo { + cfg := toGSIConfig(idx) + + return &driver.IndexInfo{ + Name: cfg.Name, + PartitionKey: cfg.PartitionKey, + SortKey: cfg.SortKey, + Status: idx.LifecycleState, + } +} + +// UpdateTTL configures the attribute-based TTL. It sits alongside the +// table-level TTL the DDL sets rather than replacing it: a row expires when +// either says so. +func (m *Mock) UpdateTTL(_ context.Context, table string, cfg driver.TTLConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return err + } + + if cfg.Enabled && cfg.AttributeName == "" { + return cerrors.New(cerrors.InvalidArgument, "an enabled TTL names an attribute") + } + + if cfg.AttributeName == ttlExpiryColumn { + return cerrors.Newf(cerrors.InvalidArgument, "%q is reserved for the table-level TTL", ttlExpiryColumn) + } + + t.ttl = cfg + + return nil +} + +// DescribeTTL returns the attribute-based TTL configuration. +func (m *Mock) DescribeTTL(_ context.Context, table string) (*driver.TTLConfig, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.lookup(table) + if err != nil { + return nil, err + } + + cfg := t.ttl + + return &cfg, nil +} + +// UpdateStreamConfig reports that OCI has no change stream. NoSQL Database +// publishes no DynamoDB-Streams or Cosmos-change-feed equivalent, so there is +// nothing to enable rather than a feature left unimplemented. +func (m *Mock) UpdateStreamConfig(_ context.Context, table string, _ driver.StreamConfig) error { + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.lookup(table); err != nil { + return err + } + + return cerrors.New(cerrors.Unimplemented, "OCI NoSQL Database publishes no change stream") +} + +// GetStreamRecords reports that OCI has no change stream. See UpdateStreamConfig. +func (m *Mock) GetStreamRecords( + _ context.Context, table string, _ int, _ string, +) (*driver.StreamIterator, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.lookup(table); err != nil { + return nil, err + } + + return nil, cerrors.New(cerrors.Unimplemented, "OCI NoSQL Database publishes no change stream") +} + +// TagResource merges freeform tags onto a table. +func (m *Mock) TagResource(_ context.Context, table string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return err + } + + if t.Tags == nil { + t.Tags = make(map[string]string, len(tags)) + } + + maps.Copy(t.Tags, tags) + t.TimeUpdated = m.now() + + return nil +} + +// UntagResource removes freeform tag keys from a table. +func (m *Mock) UntagResource(_ context.Context, table string, tagKeys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return err + } + + for _, k := range tagKeys { + delete(t.Tags, k) + } + + t.TimeUpdated = m.now() + + return nil +} + +// ListTagsOfResource returns a table's freeform tags. +func (m *Mock) ListTagsOfResource(_ context.Context, table string) (map[string]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.lookup(table) + if err != nil { + return nil, err + } + + out := make(map[string]string, len(t.Tags)) + maps.Copy(out, t.Tags) + + return out, nil +} diff --git a/providers/oci/nosql/nosql.go b/providers/oci/nosql/nosql.go new file mode 100644 index 000000000..815bae854 --- /dev/null +++ b/providers/oci/nosql/nosql.go @@ -0,0 +1,486 @@ +// Package nosql provides an in-memory mock implementation of OCI NoSQL +// Database Cloud Service. It implements the portable database driver: an OCI +// table is the DynamoDB table, its shard key the partition key and the second +// primary key column the sort key. +// +// OCI is DDL-driven, so the OCI-shaped entry points take a SQL statement and +// derive the schema from it; the OCI-only value types they return live here +// and the capability interfaces consuming them live in server/oci/nosql. +package nosql + +import ( + "context" + "fmt" + "maps" + "sort" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/database/driver" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// Compile-time check that Mock implements the portable driver. The OCI-shaped +// capabilities live in server/oci/nosql and are checked there. +var _ driver.Database = (*Mock)(nil) + +const timeFormat = time.RFC3339 + +// typeTable is the OCID resource segment for a NoSQL table. +const typeTable = "nosqltable" + +// metricNamespace is the namespace real OCI NoSQL publishes table metrics in. +const metricNamespace = "oci_nosql" + +// Table and index lifecycle states. +const ( + StateActive = "ACTIVE" + StateCreating = "CREATING" + StateDeleting = "DELETING" + StateUpdating = "UPDATING" +) + +// Capacity modes a table's limits are expressed in. +const ( + CapacityProvisioned = "PROVISIONED" + CapacityOnDemand = "ON_DEMAND" +) + +// ttlExpiryColumn carries the row expiry OCI's table-level TTL implies. The +// DDL grammar forbids a leading underscore in a column name, so it cannot +// collide with a declared column, and every read path strips it. +const ttlExpiryColumn = "_ttlExpiration" + +// defaultPageLimit is the page size a query or scan falls back to. +const defaultPageLimit = 100 + +// TableLimits is OCI's throughput and storage allocation for a table. +type TableLimits struct { + MaxReadUnits int + MaxWriteUnits int + MaxStorageInGBs int + CapacityMode string +} + +// Column is one column of a table schema, as declared in the DDL. +type Column struct { + Name string + Type string + IsNullable bool + DefaultValue string +} + +// TTL is OCI's table-level row expiry in days, set by USING TTL in the DDL. +// OCI reports a table's TTL in days, so the DDL's HOURS unit is refused +// rather than rounded into a value the schema could not report back. +type TTL struct { + Days int +} + +// Schema is what the create-table DDL declares. ShardKey and PrimaryKey are +// the full OCI key lists; the portable projection takes the shard column as +// the partition key and the remaining primary key column as the sort key. +type Schema struct { + Columns []Column + PrimaryKey []string + ShardKey []string + TTL TTL +} + +// IndexKey is one column an index is built on. +type IndexKey struct { + ColumnName string +} + +// Index is a secondary index on a table. +type Index struct { + Name string + Keys []IndexKey + LifecycleState string +} + +// Table is an OCI NoSQL table. +type Table struct { + ID string + Name string + CompartmentID string + DDLStatement string + Schema Schema + Limits TableLimits + LifecycleState string + TimeCreated string + TimeUpdated string + IsAutoReclaimable bool + FreeformTags map[string]string +} + +// TableSpec describes a table to create over the OCI surface. +type TableSpec struct { + CompartmentID string + DDLStatement string + Limits TableLimits + IsAutoReclaimable bool + FreeformTags map[string]string +} + +// TableUpdate carries the mutable fields of UpdateTable. A nil field leaves +// the stored value alone. +type TableUpdate struct { + DDLStatement string + Limits *TableLimits + IsAutoReclaimable *bool + FreeformTags map[string]string +} + +// Row is a stored row plus the metadata OCI reports alongside its value. +type Row struct { + Value map[string]any + TimeOfExpiration string +} + +// tableData is a table and the rows it holds. +type tableData struct { + ID string + Name string + DDLStatement string + Schema Schema + Limits TableLimits + LifecycleState string + TimeCreated string + TimeUpdated string + IsAutoReclaimable bool + Scope scope.Scope + Tags map[string]string + Indexes []*Index + // ttl is the portable attribute-based TTL, kept apart from the schema's + // table-level one so a portable caller and the DDL cannot overwrite + // each other. A row expires when either says so. + ttl driver.TTLConfig + items *memstore.Store[map[string]any] +} + +// Mock is an in-memory mock implementation of OCI NoSQL Database. +type Mock struct { + // mu guards the fields of stored tables and spans the reads and writes a + // single operation makes: each store locks its own map, but the table + // pointers it hands back are mutated in place, and the OCI entry points + // resolve a name or OCID before touching the rows behind it. + mu sync.RWMutex + + tables *memstore.Store[*tableData] + // names maps a table OCID onto its name, so OCI callers can address a + // table either way. + names *memstore.Store[string] + opts *config.Options + monitoring mondriver.Monitoring +} + +// New creates a new OCI NoSQL mock. +func New(opts *config.Options) *Mock { + return &Mock{ + tables: memstore.New[*tableData](), + names: memstore.New[string](), + opts: opts, + } +} + +// SetMonitoring points the mock at the monitoring service, which it publishes +// read and write unit consumption to. +func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { + m.monitoring = mon +} + +func (m *Mock) emitMetric(name string, value float64, table string) { + if m.monitoring == nil { + return + } + + _ = m.monitoring.PutMetricData(context.Background(), []mondriver.MetricDatum{{ + Namespace: metricNamespace, MetricName: name, Value: value, Unit: "Count", + Dimensions: map[string]string{"tableName": table}, Timestamp: m.opts.Clock.Now(), + }}) +} + +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(timeFormat) +} + +// lookup returns a table by name. Callers must hold m.mu. +func (m *Mock) lookup(name string) (*tableData, error) { + t, ok := m.tables.Get(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "table %q not found", name) + } + + return t, nil +} + +// resolve returns a table addressed by either its name or its OCID, which is +// what OCI's tableNameOrId path parameter accepts. Callers must hold m.mu. +func (m *Mock) resolve(nameOrID string) (*tableData, error) { + if name, ok := m.names.Get(nameOrID); ok { + return m.lookup(name) + } + + return m.lookup(nameOrID) +} + +// itemKey is a row's identity: the shard key, then the sort key when the +// primary key declares one. +func itemKey(t *tableData, item map[string]any) string { + pk := t.Schema.ShardKey[0] + key := fmt.Sprintf("%v", item[pk]) + + if sk := sortKeyOf(&t.Schema); sk != "" { + key += ":" + fmt.Sprintf("%v", item[sk]) + } + + return key +} + +// sortKeyOf returns the primary key column following the shard key, or the +// empty string for a single-column primary key. +func sortKeyOf(s *Schema) string { + if len(s.PrimaryKey) < 2 { //nolint:mnd // a sort key is the second primary key column + return "" + } + + return s.PrimaryKey[1] +} + +// toTableConfig projects a table onto the portable shape. +func toTableConfig(t *tableData) driver.TableConfig { + cfg := driver.TableConfig{ + Name: t.Name, + PartitionKey: t.Schema.ShardKey[0], + SortKey: sortKeyOf(&t.Schema), + } + + for _, idx := range t.Indexes { + cfg.GSIs = append(cfg.GSIs, toGSIConfig(idx)) + } + + return cfg +} + +// toGSIConfig projects an index onto the portable shape. An index on more +// than two columns keeps its full key list on the OCI side; the portable +// projection has room for a partition and a sort key only. +func toGSIConfig(idx *Index) driver.GSIConfig { + cfg := driver.GSIConfig{Name: idx.Name} + + if len(idx.Keys) > 0 { + cfg.PartitionKey = idx.Keys[0].ColumnName + } + + if len(idx.Keys) > 1 { + cfg.SortKey = idx.Keys[1].ColumnName + } + + return cfg +} + +// toTable projects a table onto the OCI shape. +func toTable(t *tableData) Table { + return Table{ + ID: t.ID, + Name: t.Name, + CompartmentID: t.Scope.Compartment, + DDLStatement: t.DDLStatement, + Schema: cloneSchema(&t.Schema), + Limits: t.Limits, + LifecycleState: t.LifecycleState, + TimeCreated: t.TimeCreated, + TimeUpdated: t.TimeUpdated, + IsAutoReclaimable: t.IsAutoReclaimable, + FreeformTags: maps.Clone(t.Tags), + } +} + +func cloneSchema(s *Schema) Schema { + out := *s + out.Columns = append([]Column(nil), s.Columns...) + out.PrimaryKey = append([]string(nil), s.PrimaryKey...) + out.ShardKey = append([]string(nil), s.ShardKey...) + + return out +} + +// visible copies an item without the internal expiry column, which is +// bookkeeping rather than a declared column. +func visible(item map[string]any) map[string]any { + out := make(map[string]any, len(item)) + + for k, v := range item { + if k == ttlExpiryColumn { + continue + } + + out[k] = v + } + + return out +} + +// expired reports whether a row has passed either TTL: the table-level one the +// DDL sets, or the attribute-based one a portable caller configures. +func (m *Mock) expired(t *tableData, item map[string]any) bool { + now := m.opts.Clock.Now().Unix() + + if exp, ok := toUnix(item[ttlExpiryColumn]); ok && exp > 0 && now > exp { + return true + } + + if !t.ttl.Enabled || t.ttl.AttributeName == "" { + return false + } + + exp, ok := toUnix(item[t.ttl.AttributeName]) + + return ok && exp > 0 && now > exp +} + +func toUnix(v any) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case int: + return int64(n), true + case float64: + return int64(n), true + } + + return 0, false +} + +// liveItems returns the table's unexpired rows, stripped of bookkeeping. +// Callers must hold m.mu. +func (m *Mock) liveItems(t *tableData) []map[string]any { + all := t.items.All() + out := make([]map[string]any, 0, len(all)) + + for _, item := range all { + if m.expired(t, item) { + continue + } + + out = append(out, visible(item)) + } + + return out +} + +// CreateTable creates a table from the portable config. OCI is DDL-driven, so +// the equivalent statement is synthesized and reported by GetTable; every +// column takes OCI's STRING type, which is all the portable shape declares. +func (m *Mock) CreateTable(_ context.Context, cfg driver.TableConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + + if cfg.Name == "" { + return cerrors.New(cerrors.InvalidArgument, "table name is required") + } + + if cfg.PartitionKey == "" { + return cerrors.New(cerrors.InvalidArgument, "partition key is required") + } + + schema := schemaFromConfig(&cfg) + + t, err := m.newTable(cfg.Name, ddlFromSchema(cfg.Name, &schema), &schema, defaultLimits()) + if err != nil { + return err + } + + for i := range cfg.GSIs { + if _, err := m.addIndex(t, indexFromGSI(&cfg.GSIs[i])); err != nil { + return err + } + } + + return nil +} + +// defaultLimits is what a table created through the portable API gets: OCI +// requires limits, and on-demand is the mode that names no numbers. +func defaultLimits() TableLimits { + return TableLimits{CapacityMode: CapacityOnDemand} +} + +// newTable records a new table. Callers must hold m.mu. +func (m *Mock) newTable(name, ddl string, schema *Schema, limits TableLimits) (*tableData, error) { + if m.tables.Has(name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "table %q already exists", name) + } + + now := m.now() + t := &tableData{ + ID: idgen.OCID(typeTable, m.opts.Realm, m.opts.OCIRegion()), + Name: name, + DDLStatement: ddl, + Schema: *schema, + Limits: limits, + LifecycleState: StateActive, + TimeCreated: now, + TimeUpdated: now, + Scope: scope.Scope{Compartment: m.opts.CompartmentID}, + items: memstore.New[map[string]any](), + } + + m.tables.Set(name, t) + m.names.Set(t.ID, name) + + return t, nil +} + +// DeleteTable deletes a table and the rows in it. +func (m *Mock) DeleteTable(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + return m.dropTable(name) +} + +// dropTable removes a table by name. Callers must hold m.mu. +func (m *Mock) dropTable(name string) error { + t, err := m.lookup(name) + if err != nil { + return err + } + + m.tables.Delete(name) + m.names.Delete(t.ID) + + return nil +} + +// DescribeTable returns the portable projection of a table. +func (m *Mock) DescribeTable(_ context.Context, name string) (*driver.TableConfig, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.lookup(name) + if err != nil { + return nil, err + } + + cfg := toTableConfig(t) + + return &cfg, nil +} + +// ListTables returns every table name, ordered. +func (m *Mock) ListTables(_ context.Context) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + names := m.tables.Keys() + sort.Strings(names) + + return names, nil +} diff --git a/providers/oci/nosql/nosql_test.go b/providers/oci/nosql/nosql_test.go new file mode 100644 index 000000000..2994ba5dc --- /dev/null +++ b/providers/oci/nosql/nosql_test.go @@ -0,0 +1,931 @@ +package nosql_test + +import ( + "context" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +const ( + compartmentA = "ocid1.compartment.oc1..aaaa" + compartmentB = "ocid1.compartment.oc1..bbbb" + + usersDDL = "CREATE TABLE users (id INTEGER, email STRING, name STRING, PRIMARY KEY (SHARD(id), email))" +) + +func newMock(t *testing.T) (*nosql.Mock, *config.FakeClock) { + t.Helper() + + clock := config.NewFakeClock(time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)) + m := nosql.New(config.NewOptions( + config.WithClock(clock), + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(compartmentA), + )) + + return m, clock +} + +// provisioned is the limits shape a table needs when it is not on demand. +func provisioned() nosql.TableLimits { + return nosql.TableLimits{ + MaxReadUnits: 50, MaxWriteUnits: 50, MaxStorageInGBs: 1, CapacityMode: nosql.CapacityProvisioned, + } +} + +func createUsers(t *testing.T, m *nosql.Mock) *nosql.Table { + t.Helper() + + table, err := m.CreateOCITable(context.Background(), nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: usersDDL, + Limits: provisioned(), + }) + require.NoError(t, err) + + return table +} + +func TestCreateOCITable(t *testing.T) { + m, _ := newMock(t) + table := createUsers(t, m) + + assert.Equal(t, "users", table.Name) + assert.Equal(t, compartmentA, table.CompartmentID) + assert.Equal(t, nosql.StateActive, table.LifecycleState) + assert.Equal(t, []string{"id"}, table.Schema.ShardKey) + assert.Equal(t, []string{"id", "email"}, table.Schema.PrimaryKey) + assert.Equal(t, 50, table.Limits.MaxReadUnits) + assert.NotEmpty(t, table.TimeCreated) +} + +// TestTableOCIDShape pins the identifier form real OCI mints for a NoSQL table. +func TestTableOCIDShape(t *testing.T) { + m, _ := newMock(t) + table := createUsers(t, m) + + assert.Regexp(t, regexp.MustCompile(`^ocid1\.nosqltable\.oc1\.iad\.a[a-z0-9]+$`), table.ID) +} + +func TestCreateOCITableErrors(t *testing.T) { + tests := []struct { + name string + spec nosql.TableSpec + expectCode cerrors.Code + expectWord string + }{ + { + name: "missing compartment", + spec: nosql.TableSpec{DDLStatement: usersDDL, Limits: provisioned()}, + expectCode: cerrors.InvalidArgument, + expectWord: "compartmentId is required", + }, + { + name: "alter statement passed to create", + spec: nosql.TableSpec{ + CompartmentID: compartmentA, DDLStatement: "ALTER TABLE users (ADD x STRING)", Limits: provisioned(), + }, + expectCode: cerrors.InvalidArgument, + expectWord: "CreateTable takes a CREATE TABLE statement", + }, + { + name: "on demand table naming read units", + spec: nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: usersDDL, + Limits: nosql.TableLimits{MaxReadUnits: 10, CapacityMode: nosql.CapacityOnDemand}, + }, + expectCode: cerrors.InvalidArgument, + expectWord: "ON_DEMAND table sets no maxReadUnits", + }, + { + name: "provisioned table naming no units", + spec: nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: usersDDL, + Limits: nosql.TableLimits{CapacityMode: nosql.CapacityProvisioned}, + }, + expectCode: cerrors.InvalidArgument, + expectWord: "PROVISIONED table sets maxReadUnits", + }, + { + name: "unknown capacity mode", + spec: nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: usersDDL, + Limits: nosql.TableLimits{CapacityMode: "ELASTIC"}, + }, + expectCode: cerrors.InvalidArgument, + expectWord: `capacityMode "ELASTIC"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newMock(t) + + _, err := m.CreateOCITable(context.Background(), tc.spec) + + require.Error(t, err) + assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.expectWord) + }) + } +} + +func TestCreateOCITableAlreadyExists(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.CreateOCITable(context.Background(), nosql.TableSpec{ + CompartmentID: compartmentA, DDLStatement: usersDDL, Limits: provisioned(), + }) + + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +func TestCreateOCITableIfNotExistsIsIdempotent(t *testing.T) { + m, _ := newMock(t) + first := createUsers(t, m) + + second, err := m.CreateOCITable(context.Background(), nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: "CREATE TABLE IF NOT EXISTS users (id INTEGER, email STRING, PRIMARY KEY (SHARD(id), email))", + Limits: provisioned(), + }) + require.NoError(t, err) + + assert.Equal(t, first.ID, second.ID) +} + +func TestGetOCITableByNameOrOCID(t *testing.T) { + m, _ := newMock(t) + table := createUsers(t, m) + + for _, addr := range []string{"users", table.ID} { + got, err := m.GetOCITable(context.Background(), addr) + require.NoError(t, err) + assert.Equal(t, table.ID, got.ID) + } +} + +func TestGetOCITableNotFound(t *testing.T) { + m, _ := newMock(t) + + _, err := m.GetOCITable(context.Background(), "missing") + + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +// TestListOCITablesFiltersByCompartment is the compartment-isolation contract: +// a table created in one compartment must not appear in another's listing. +func TestListOCITablesFiltersByCompartment(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.CreateOCITable(context.Background(), nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: "CREATE TABLE audits (id STRING, PRIMARY KEY (id))", + Limits: provisioned(), + }) + require.NoError(t, err) + + tests := []struct { + name string + compartment string + filterName string + expect []string + }{ + {name: "compartment a", compartment: compartmentA, expect: []string{"users"}}, + {name: "compartment b", compartment: compartmentB, expect: []string{"audits"}}, + {name: "unknown compartment lists nothing", compartment: "ocid1.compartment.oc1..zzz", expect: []string{}}, + {name: "name narrows the listing", compartment: compartmentA, filterName: "users", expect: []string{"users"}}, + {name: "name matching nothing", compartment: compartmentA, filterName: "audits", expect: []string{}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := m.ListOCITables(context.Background(), tc.compartment, tc.filterName) + require.NoError(t, err) + + names := make([]string, 0, len(got)) + for _, table := range got { + names = append(names, table.Name) + } + + assert.Equal(t, tc.expect, names) + }) + } +} + +func TestChangeOCITableCompartment(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + require.NoError(t, m.ChangeOCITableCompartment(context.Background(), "users", compartmentB)) + + inA, err := m.ListOCITables(context.Background(), compartmentA, "") + require.NoError(t, err) + assert.Empty(t, inA) + + inB, err := m.ListOCITables(context.Background(), compartmentB, "") + require.NoError(t, err) + require.Len(t, inB, 1) + assert.Equal(t, "users", inB[0].Name) +} + +func TestUpdateOCITable(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + reclaim := true + + table, err := m.UpdateOCITable(context.Background(), "users", nosql.TableUpdate{ + DDLStatement: "ALTER TABLE users (ADD nickname STRING)", + Limits: &nosql.TableLimits{CapacityMode: nosql.CapacityOnDemand}, + IsAutoReclaimable: &reclaim, + FreeformTags: map[string]string{"env": "dev"}, + }) + require.NoError(t, err) + + assert.Len(t, table.Schema.Columns, 4) + assert.Equal(t, nosql.CapacityOnDemand, table.Limits.CapacityMode) + assert.True(t, table.IsAutoReclaimable) + assert.Equal(t, map[string]string{"env": "dev"}, table.FreeformTags) + assert.Contains(t, table.DDLStatement, "nickname STRING") +} + +func TestUpdateOCITableErrors(t *testing.T) { + tests := []struct { + name string + table string + update nosql.TableUpdate + expectCode cerrors.Code + expectWord string + }{ + { + name: "unknown table", + table: "missing", + update: nosql.TableUpdate{DDLStatement: "ALTER TABLE missing (ADD x STRING)"}, + expectCode: cerrors.NotFound, + expectWord: "not found", + }, + { + name: "create statement passed to update", + table: "users", + update: nosql.TableUpdate{DDLStatement: usersDDL}, + expectCode: cerrors.InvalidArgument, + expectWord: "UpdateTable takes an ALTER TABLE statement", + }, + { + name: "alter names another table", + table: "users", + update: nosql.TableUpdate{DDLStatement: "ALTER TABLE other (ADD x STRING)"}, + expectCode: cerrors.InvalidArgument, + expectWord: `names table "other"`, + }, + { + name: "dropping a key column", + table: "users", + update: nosql.TableUpdate{DDLStatement: "ALTER TABLE users (DROP id)"}, + expectCode: cerrors.InvalidArgument, + expectWord: "part of the primary key", + }, + { + name: "dropping an undeclared column", + table: "users", + update: nosql.TableUpdate{DDLStatement: "ALTER TABLE users (DROP nope)"}, + expectCode: cerrors.NotFound, + expectWord: "is not declared", + }, + { + name: "adding a column twice", + table: "users", + update: nosql.TableUpdate{DDLStatement: "ALTER TABLE users (ADD name STRING)"}, + expectCode: cerrors.AlreadyExists, + expectWord: "already declared", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.UpdateOCITable(context.Background(), tc.table, tc.update) + + require.Error(t, err) + assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.expectWord) + }) + } +} + +func TestDeleteOCITable(t *testing.T) { + m, _ := newMock(t) + table := createUsers(t, m) + + require.NoError(t, m.DeleteOCITable(context.Background(), table.ID)) + + _, err := m.GetOCITable(context.Background(), "users") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + assert.Equal(t, cerrors.NotFound, + cerrors.GetCode(m.DeleteOCITable(context.Background(), "users"))) +} + +func TestOCIRowRoundTrip(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.PutOCIRow(context.Background(), "users", + map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"}, "") + require.NoError(t, err) + + row, err := m.GetOCIRow(context.Background(), "users", map[string]string{"id": "1", "email": "a@example.com"}) + require.NoError(t, err) + + assert.Equal(t, "Ada", row.Value["name"]) + // An INTEGER column comes back as an integer, not JSON's float64. + assert.Equal(t, int64(1), row.Value["id"]) + assert.Empty(t, row.TimeOfExpiration) + + deleted, err := m.DeleteOCIRow(context.Background(), "users", + map[string]string{"id": "1", "email": "a@example.com"}) + require.NoError(t, err) + assert.True(t, deleted) + + _, err = m.GetOCIRow(context.Background(), "users", map[string]string{"id": "1", "email": "a@example.com"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestDeleteOCIRowReportsAbsence(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + deleted, err := m.DeleteOCIRow(context.Background(), "users", map[string]string{"id": "9", "email": "x@y.z"}) + require.NoError(t, err) + assert.False(t, deleted) +} + +func TestPutOCIRowOptions(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + row := map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"} + + _, err := m.PutOCIRow(context.Background(), "users", row, nosql.OptionIfPresent) + require.Error(t, err) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + _, err = m.PutOCIRow(context.Background(), "users", row, nosql.OptionIfAbsent) + require.NoError(t, err) + + _, err = m.PutOCIRow(context.Background(), "users", row, nosql.OptionIfAbsent) + require.Error(t, err) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + _, err = m.PutOCIRow(context.Background(), "users", row, "MAYBE") + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +func TestPutOCIRowValidation(t *testing.T) { + tests := []struct { + name string + value map[string]any + expectWord string + }{ + { + name: "undeclared column", + value: map[string]any{"id": float64(1), "email": "a@b.c", "bogus": "x"}, + expectWord: `column "bogus" is not declared`, + }, + { + name: "missing key column", + value: map[string]any{"id": float64(1)}, + expectWord: `primary key column "email" is missing`, + }, + { + name: "string in an integer column", + value: map[string]any{"id": "one", "email": "a@b.c"}, + expectWord: `does not fit column "id" of type INTEGER`, + }, + { + name: "fractional value in an integer column", + value: map[string]any{"id": 1.5, "email": "a@b.c"}, + expectWord: `does not fit column "id" of type INTEGER`, + }, + { + name: "number in a string column", + value: map[string]any{"id": float64(1), "email": float64(2)}, + expectWord: `does not fit column "email" of type STRING`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.PutOCIRow(context.Background(), "users", tc.value, "") + + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.expectWord) + }) + } +} + +func TestGetOCIRowRejectsNonKeyColumn(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.GetOCIRow(context.Background(), "users", map[string]string{"name": "Ada"}) + + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), "is not a primary key column") +} + +// TestTableTTLExpiresRows exercises OCI's table-level USING TTL, which expires +// a row a fixed span after it is written rather than at an attribute's value. +func TestTableTTLExpiresRows(t *testing.T) { + m, clock := newMock(t) + + _, err := m.CreateOCITable(context.Background(), nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: "CREATE TABLE sessions (id STRING, PRIMARY KEY (id)) USING TTL 2 DAYS", + Limits: provisioned(), + }) + require.NoError(t, err) + + written, err := m.PutOCIRow(context.Background(), "sessions", map[string]any{"id": "s1"}, "") + require.NoError(t, err) + assert.NotEmpty(t, written.TimeOfExpiration) + + row, err := m.GetOCIRow(context.Background(), "sessions", map[string]string{"id": "s1"}) + require.NoError(t, err) + assert.NotEmpty(t, row.TimeOfExpiration) + // The expiry is metadata, not a column the caller sees in the value. + assert.Len(t, row.Value, 1) + + clock.Advance(3 * 24 * time.Hour) + + _, err = m.GetOCIRow(context.Background(), "sessions", map[string]string{"id": "s1"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestOCIIndexes(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + spec := nosql.IndexSpec{Name: "byName", Columns: []string{"name"}} + + idx, err := m.CreateOCIIndex(context.Background(), "users", spec, false) + require.NoError(t, err) + assert.Equal(t, nosql.StateActive, idx.LifecycleState) + assert.Equal(t, []nosql.IndexKey{{ColumnName: "name"}}, idx.Keys) + + _, err = m.CreateOCIIndex(context.Background(), "users", spec, false) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + again, err := m.CreateOCIIndex(context.Background(), "users", spec, true) + require.NoError(t, err) + assert.Equal(t, "byName", again.Name) + + got, err := m.GetOCIIndex(context.Background(), "users", "byName") + require.NoError(t, err) + assert.Equal(t, "byName", got.Name) + + list, err := m.ListOCIIndexes(context.Background(), "users", "") + require.NoError(t, err) + require.Len(t, list, 1) + + require.NoError(t, m.DeleteOCIIndex(context.Background(), "users", "byName", false)) + + err = m.DeleteOCIIndex(context.Background(), "users", "byName", false) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + require.NoError(t, m.DeleteOCIIndex(context.Background(), "users", "byName", true)) +} + +func TestCreateOCIIndexRejectsUndeclaredColumn(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.CreateOCIIndex(context.Background(), "users", + nosql.IndexSpec{Name: "bad", Columns: []string{"nope"}}, false) + + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), `index column "nope" is not declared`) +} + +func TestQueryOCISelectAndDelete(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + for _, email := range []string{"a@x.com", "b@x.com"} { + _, err := m.PutOCIRow(context.Background(), "users", + map[string]any{"id": float64(1), "email": email, "name": "Ada"}, "") + require.NoError(t, err) + } + + _, err := m.PutOCIRow(context.Background(), "users", + map[string]any{"id": float64(2), "email": "c@x.com", "name": "Grace"}, "") + require.NoError(t, err) + + rows, err := m.QueryOCI(context.Background(), compartmentA, "SELECT * FROM users", 0) + require.NoError(t, err) + assert.Len(t, rows, 3) + + rows, err = m.QueryOCI(context.Background(), compartmentA, "SELECT * FROM users WHERE name = 'Ada'", 0) + require.NoError(t, err) + assert.Len(t, rows, 2) + + rows, err = m.QueryOCI(context.Background(), compartmentA, "SELECT * FROM users", 1) + require.NoError(t, err) + assert.Len(t, rows, 1) + + // The multi-delete path: OCI's REST API has no MultiDelete operation, so + // DELETE FROM over the query endpoint is how several rows go at once. + rows, err = m.QueryOCI(context.Background(), compartmentA, "DELETE FROM users WHERE id = 1", 0) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, 2, rows[0]["NumRowsDeleted"]) + + rows, err = m.QueryOCI(context.Background(), compartmentA, "SELECT * FROM users", 0) + require.NoError(t, err) + assert.Len(t, rows, 1) +} + +func TestQueryOCIRejectsUnsupported(t *testing.T) { + tests := []struct { + name string + compartment string + statement string + expectCode cerrors.Code + expectWord string + }{ + { + name: "no compartment", + statement: "SELECT * FROM users", + expectCode: cerrors.InvalidArgument, + expectWord: "compartmentId is required", + }, + { + name: "insert", + compartment: compartmentA, + statement: "INSERT INTO users VALUES (1, 'a@x.com')", + expectCode: cerrors.InvalidArgument, + expectWord: "unsupported statement", + }, + { + name: "column projection", + compartment: compartmentA, + statement: "SELECT name FROM users", + expectCode: cerrors.InvalidArgument, + expectWord: "only SELECT * is supported", + }, + { + name: "order by", + compartment: compartmentA, + statement: "SELECT * FROM users ORDER BY name", + expectCode: cerrors.InvalidArgument, + expectWord: "unsupported clause", + }, + { + name: "range condition", + compartment: compartmentA, + statement: "SELECT * FROM users WHERE id > 1", + expectCode: cerrors.InvalidArgument, + expectWord: "unsupported condition", + }, + { + name: "undeclared column in the condition", + compartment: compartmentA, + statement: "SELECT * FROM users WHERE nope = 'x'", + expectCode: cerrors.InvalidArgument, + expectWord: `column "nope" is not declared`, + }, + { + name: "table in another compartment", + compartment: compartmentB, + statement: "SELECT * FROM users", + expectCode: cerrors.NotFound, + expectWord: "not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.QueryOCI(context.Background(), tc.compartment, tc.statement, 0) + + require.Error(t, err) + assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.expectWord) + }) + } +} + +// --- portable driver surface --- + +func TestPortableTableCRUD(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + cfg := driver.TableConfig{Name: "portable", PartitionKey: "pk", SortKey: "sk"} + require.NoError(t, m.CreateTable(ctx, cfg)) + + err := m.CreateTable(ctx, cfg) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + got, err := m.DescribeTable(ctx, "portable") + require.NoError(t, err) + assert.Equal(t, "pk", got.PartitionKey) + assert.Equal(t, "sk", got.SortKey) + + names, err := m.ListTables(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"portable"}, names) + + // A table created portably still reports the DDL OCI callers expect. + table, err := m.GetOCITable(ctx, "portable") + require.NoError(t, err) + assert.Equal(t, "CREATE TABLE portable (pk STRING, sk STRING, PRIMARY KEY (SHARD(pk), sk))", table.DDLStatement) + assert.Equal(t, nosql.CapacityOnDemand, table.Limits.CapacityMode) + + require.NoError(t, m.DeleteTable(ctx, "portable")) + + _, err = m.DescribeTable(ctx, "portable") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteTable(ctx, "portable"))) +} + +func TestPortableTableErrors(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(m.CreateTable(ctx, driver.TableConfig{}))) + assert.Equal(t, cerrors.InvalidArgument, + cerrors.GetCode(m.CreateTable(ctx, driver.TableConfig{Name: "t"}))) +} + +func TestPortableItemCRUD(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk", SortKey: "sk"})) + + item := map[string]any{"pk": "a", "sk": "1", "v": "x"} + require.NoError(t, m.PutItem(ctx, "t", item)) + + got, err := m.GetItem(ctx, "t", map[string]any{"pk": "a", "sk": "1"}) + require.NoError(t, err) + assert.Equal(t, "x", got["v"]) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t", + Key: map[string]any{"pk": "a", "sk": "1"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "y"}, + {Action: "REMOVE", Field: "gone"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "y", updated["v"]) + + _, err = m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t", + Key: map[string]any{"pk": "a", "sk": "1"}, + Actions: []driver.UpdateAction{{Action: "MULTIPLY", Field: "v"}}, + }) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + require.NoError(t, m.DeleteItem(ctx, "t", map[string]any{"pk": "a", "sk": "1"})) + + _, err = m.GetItem(ctx, "t", map[string]any{"pk": "a", "sk": "1"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestPortableItemOpsOnMissingTable(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.PutItem(ctx, "nope", map[string]any{}))) + + _, err := m.GetItem(ctx, "nope", map[string]any{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteItem(ctx, "nope", map[string]any{}))) + + _, err = m.Scan(ctx, driver.ScanInput{Table: "nope"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.Query(ctx, driver.QueryInput{Table: "nope"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestPortableQueryAndScan(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.BatchPutItems(ctx, "t", []map[string]any{ + {"pk": "a", "sk": "1", "v": "x"}, + {"pk": "a", "sk": "2", "v": "y"}, + {"pk": "b", "sk": "1", "v": "z"}, + })) + + res, err := m.Query(ctx, driver.QueryInput{ + Table: "t", + KeyCondition: driver.KeyCondition{PartitionKey: "pk", PartitionVal: "a"}, + }) + require.NoError(t, err) + assert.Equal(t, 2, res.Count) + + res, err = m.Query(ctx, driver.QueryInput{ + Table: "t", + KeyCondition: driver.KeyCondition{PartitionKey: "pk", PartitionVal: "a", SortOp: "=", SortVal: "2"}, + }) + require.NoError(t, err) + require.Equal(t, 1, res.Count) + assert.Equal(t, "y", res.Items[0]["v"]) + + res, err = m.Scan(ctx, driver.ScanInput{Table: "t", Filters: []driver.ScanFilter{{Field: "v", Op: "=", Value: "z"}}}) + require.NoError(t, err) + assert.Equal(t, 1, res.Count) + + _, err = m.Query(ctx, driver.QueryInput{Table: "t", IndexName: "missing"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + items, err := m.BatchGetItems(ctx, "t", []map[string]any{{"pk": "a", "sk": "1"}, {"pk": "z", "sk": "9"}}) + require.NoError(t, err) + assert.Len(t, items, 1) +} + +func TestPortableTransactWriteItems(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + require.NoError(t, m.PutItem(ctx, "t", map[string]any{"pk": "gone"})) + + require.NoError(t, m.TransactWriteItems(ctx, "t", + []map[string]any{{"pk": "new"}}, + []map[string]any{{"pk": "gone"}})) + + res, err := m.Scan(ctx, driver.ScanInput{Table: "t"}) + require.NoError(t, err) + require.Equal(t, 1, res.Count) + assert.Equal(t, "new", res.Items[0]["pk"]) + + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.TransactWriteItems(ctx, "nope", nil, nil))) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.BatchPutItems(ctx, "nope", nil))) +} + +func TestPortableIndexes(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{ + Name: "t", PartitionKey: "pk", SortKey: "sk", + GSIs: []driver.GSIConfig{{Name: "bySK", PartitionKey: "sk"}}, + })) + + list, err := m.ListIndexes(ctx, "t") + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, "bySK", list[0].Name) + + info, err := m.DescribeIndex(ctx, "t", "bySK") + require.NoError(t, err) + assert.Equal(t, nosql.StateActive, info.Status) + + _, err = m.CreateIndex(ctx, "t", driver.GSIConfig{Name: "bySK", PartitionKey: "sk"}) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + require.NoError(t, m.DeleteIndex(ctx, "t", "bySK")) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteIndex(ctx, "t", "bySK"))) + + _, err = m.DescribeIndex(ctx, "t", "bySK") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.CreateIndex(ctx, "nope", driver.GSIConfig{Name: "x", PartitionKey: "pk"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestPortableAttributeTTL(t *testing.T) { + m, clock := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + require.NoError(t, m.UpdateTTL(ctx, "t", driver.TTLConfig{Enabled: true, AttributeName: "expiresAt"})) + + cfg, err := m.DescribeTTL(ctx, "t") + require.NoError(t, err) + assert.True(t, cfg.Enabled) + + require.NoError(t, m.PutItem(ctx, "t", map[string]any{ + "pk": "a", "expiresAt": clock.Now().Add(time.Hour).Unix(), + })) + + _, err = m.GetItem(ctx, "t", map[string]any{"pk": "a"}) + require.NoError(t, err) + + clock.Advance(2 * time.Hour) + + _, err = m.GetItem(ctx, "t", map[string]any{"pk": "a"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestUpdateTTLRejectsReservedAttribute(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + + err := m.UpdateTTL(ctx, "t", driver.TTLConfig{Enabled: true, AttributeName: "_ttlExpiration"}) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + err = m.UpdateTTL(ctx, "t", driver.TTLConfig{Enabled: true}) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +// TestStreamsAreUnimplemented pins the deliberate gap: OCI NoSQL publishes no +// DynamoDB-Streams or Cosmos-change-feed equivalent, so the portable stream +// operations report that rather than silently returning nothing. +func TestStreamsAreUnimplemented(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + + err := m.UpdateStreamConfig(ctx, "t", driver.StreamConfig{Enabled: true}) + require.Error(t, err) + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), "no change stream") + + _, err = m.GetStreamRecords(ctx, "t", 10, "") + require.Error(t, err) + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(err)) + + // An unknown table is still a 404 rather than the capability gap. + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.UpdateStreamConfig(ctx, "nope", driver.StreamConfig{}))) + + _, err = m.GetStreamRecords(ctx, "nope", 10, "") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestPortableTags(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + require.NoError(t, m.TagResource(ctx, "t", map[string]string{"env": "dev", "team": "core"})) + + tags, err := m.ListTagsOfResource(ctx, "t") + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "dev", "team": "core"}, tags) + + require.NoError(t, m.UntagResource(ctx, "t", []string{"team"})) + + tags, err = m.ListTagsOfResource(ctx, "t") + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "dev"}, tags) + + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.TagResource(ctx, "nope", nil))) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.UntagResource(ctx, "nope", nil))) + + _, err = m.ListTagsOfResource(ctx, "nope") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestOCITableScope(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + assert.Equal(t, compartmentA, m.OCITableScope("users")) + assert.Empty(t, m.OCITableScope("missing")) +} diff --git a/providers/oci/nosql/query.go b/providers/oci/nosql/query.go new file mode 100644 index 000000000..474e89c39 --- /dev/null +++ b/providers/oci/nosql/query.go @@ -0,0 +1,304 @@ +package nosql + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// Statement kinds the query endpoint runs. OCI's NoSQL REST API has no +// multi-delete operation of its own; DELETE FROM over the query endpoint is +// how several rows go at once. +const ( + dmlSelect = "SELECT" + dmlDelete = "DELETE FROM" +) + +// deletedRowsField is the column OCI reports a DELETE's row count in. +const deletedRowsField = "NumRowsDeleted" + +// condition is one `column = literal` equality test. +type condition struct { + Column string + Value string +} + +// QueryOCI runs one SELECT or DELETE statement against a table in the given +// compartment. Only `SELECT *` and `DELETE FROM`, each with AND-ed equality +// conditions on declared columns, are parsed; anything else is refused by +// name rather than accepted and quietly reinterpreted. +func (m *Mock) QueryOCI(_ context.Context, compartmentID, statement string, limit int) ([]map[string]any, error) { + if compartmentID == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + stmt := normaliseStatement(statement) + upper := strings.ToUpper(stmt) + + switch { + case strings.HasPrefix(upper, dmlSelect): + return m.runSelect(compartmentID, stmt, limit) + case strings.HasPrefix(upper, dmlDelete): + return m.runDelete(compartmentID, stmt) + } + + return nil, cerrors.Newf(cerrors.InvalidArgument, + "unsupported statement %q; CloudEmu's query endpoint runs SELECT * and DELETE FROM over one table "+ + "with AND-ed equality conditions", leadingWords(stmt)) +} + +func (m *Mock) runSelect(compartmentID, stmt string, limit int) ([]map[string]any, error) { + table, conds, err := parseDML(stmt, dmlSelect) + if err != nil { + return nil, err + } + + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.scopedTable(table, compartmentID) + if err != nil { + return nil, err + } + + matched, err := m.matchRows(t, conds) + if err != nil { + return nil, err + } + + if limit > 0 && len(matched) > limit { + matched = matched[:limit] + } + + out := make([]map[string]any, 0, len(matched)) + for _, item := range matched { + out = append(out, visible(item)) + } + + return out, nil +} + +func (m *Mock) runDelete(compartmentID, stmt string) ([]map[string]any, error) { + table, conds, err := parseDML(stmt, dmlDelete) + if err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.scopedTable(table, compartmentID) + if err != nil { + return nil, err + } + + matched, err := m.matchRows(t, conds) + if err != nil { + return nil, err + } + + for _, item := range matched { + t.items.Delete(itemKey(t, item)) + } + + return []map[string]any{{deletedRowsField: len(matched)}}, nil +} + +// scopedTable resolves a table and checks it is visible from the caller's +// compartment. Callers must hold m.mu. +func (m *Mock) scopedTable(name, compartmentID string) (*tableData, error) { + t, err := m.resolve(name) + if err != nil { + return nil, err + } + + if !t.Scope.Matches(scope.Scope{Compartment: compartmentID}) { + return nil, cerrors.Newf(cerrors.NotFound, "table %q not found", name) + } + + return t, nil +} + +// matchRows returns the unexpired rows satisfying every condition, in a +// deterministic order. Callers must hold m.mu. +func (m *Mock) matchRows(t *tableData, conds []condition) ([]map[string]any, error) { + for _, c := range conds { + if columnIndex(t, c.Column) < 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "column %q is not declared on table %q", c.Column, t.Name) + } + } + + var matched []map[string]any + + for _, key := range sortedKeys(t) { + item, ok := t.items.Get(key) + if !ok || m.expired(t, item) { + continue + } + + if rowMatches(item, conds) { + matched = append(matched, item) + } + } + + return matched, nil +} + +func sortedKeys(t *tableData) []string { + keys := t.items.Keys() + sort.Strings(keys) + + return keys +} + +func rowMatches(item map[string]any, conds []condition) bool { + for _, c := range conds { + if fmt.Sprintf("%v", item[c.Column]) != c.Value { + return false + } + } + + return true +} + +// parseDML splits " [*] FROM table [WHERE conds]" and refuses the +// clauses the mock does not run. +func parseDML(stmt, verb string) (table string, conds []condition, err error) { + rest := strings.TrimSpace(stmt[len(verb):]) + + if verb == dmlSelect { + if !strings.HasPrefix(rest, "*") { + return "", nil, cerrors.New(cerrors.InvalidArgument, + "only SELECT * is supported; column projections, aggregates and joins are not") + } + + rest = strings.TrimSpace(rest[1:]) + + if !strings.HasPrefix(strings.ToUpper(rest), "FROM ") { + return "", nil, cerrors.New(cerrors.InvalidArgument, "SELECT * must be followed by FROM ") + } + + rest = strings.TrimSpace(rest[len("FROM "):]) + } + + words := strings.Fields(rest) + if len(words) == 0 { + return "", nil, cerrors.New(cerrors.InvalidArgument, "statement names no table") + } + + if table, err = requireIdentifier(words[0], "table name"); err != nil { + return "", nil, err + } + + tail := strings.Join(words[1:], " ") + if tail == "" { + return table, nil, nil + } + + if !strings.HasPrefix(strings.ToUpper(tail), "WHERE ") { + return "", nil, cerrors.Newf(cerrors.InvalidArgument, + "unsupported clause %q; CloudEmu runs a WHERE of AND-ed equality conditions and nothing else", tail) + } + + conds, err = parseConditions(tail[len("WHERE "):]) + + return table, conds, err +} + +// parseConditions reads `col = literal [AND col = literal]...`. +func parseConditions(clause string) ([]condition, error) { + var out []condition + + for _, part := range splitAnd(clause) { + eq := strings.IndexByte(part, '=') + if eq < 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "unsupported condition %q; CloudEmu compares a column to a literal with =", part) + } + + col, err := requireIdentifier(part[:eq], "column name") + if err != nil { + return nil, err + } + + literal, err := parseLiteral(strings.TrimSpace(part[eq+1:])) + if err != nil { + return nil, err + } + + out = append(out, condition{Column: col, Value: literal}) + } + + if len(out) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "WHERE names no conditions") + } + + return out, nil +} + +// splitAnd splits on the AND keyword, case-insensitively. +func splitAnd(clause string) []string { + var ( + out []string + words = strings.Fields(clause) + cur []string + ) + + for _, w := range words { + if strings.EqualFold(w, "AND") { + out = append(out, strings.Join(cur, " ")) + cur = nil + + continue + } + + if strings.EqualFold(w, "OR") { + // Reported as an unsupported condition by parseConditions, which + // sees the OR still embedded in the part it cannot read. + cur = append(cur, w) + + continue + } + + cur = append(cur, w) + } + + if len(cur) > 0 { + out = append(out, strings.Join(cur, " ")) + } + + return out +} + +// parseLiteral normalises a quoted string, number or boolean to the text form +// row values are compared in. +func parseLiteral(raw string) (string, error) { + if raw == "" { + return "", cerrors.New(cerrors.InvalidArgument, "condition names no value") + } + + if (strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`)) || + (strings.HasPrefix(raw, `'`) && strings.HasSuffix(raw, `'`)) { + if len(raw) < 2 { //nolint:mnd // a quoted literal is at least both quotes + return "", cerrors.Newf(cerrors.InvalidArgument, "unterminated literal %q", raw) + } + + return raw[1 : len(raw)-1], nil + } + + if strings.EqualFold(raw, "true") || strings.EqualFold(raw, "false") { + return strings.ToLower(raw), nil + } + + if _, err := strconv.ParseFloat(raw, 64); err != nil { + return "", cerrors.Newf(cerrors.InvalidArgument, + "literal %q is not a quoted string, a number or a boolean", raw) + } + + return raw, nil +} diff --git a/providers/oci/nosql/race_test.go b/providers/oci/nosql/race_test.go new file mode 100644 index 000000000..fdf91a658 --- /dev/null +++ b/providers/oci/nosql/race_test.go @@ -0,0 +1,180 @@ +package nosql_test + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// The table store hands back pointers, so an ALTER mutates the very record a +// concurrent GetTable is projecting, and a row write mutates the store a +// concurrent scan is walking. These tests fail under -race if the Mock's mutex +// is dropped from either path. + +const raceGoroutines = 16 + +func TestConcurrentRowsAndTableReads(t *testing.T) { + t.Parallel() + + m, _ := newMock(t) + ctx := context.Background() + createUsers(t, m) + + var wg sync.WaitGroup + + for i := range raceGoroutines { + wg.Add(5) + + go func() { + defer wg.Done() + + _, err := m.PutOCIRow(ctx, "users", map[string]any{ + "id": float64(i), "email": fmt.Sprintf("u%d@x.com", i), "name": "n", + }, "") + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + // Missing rows are expected while the writers are still running. + _, _ = m.GetOCIRow(ctx, "users", map[string]string{ + "id": fmt.Sprint(i), "email": fmt.Sprintf("u%d@x.com", i), + }) + }() + + go func() { + defer wg.Done() + + _, err := m.GetOCITable(ctx, "users") + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + _, err := m.ListOCITables(ctx, compartmentA, "") + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + _, err := m.Scan(ctx, driver.ScanInput{Table: "users"}) + assert.NoError(t, err) + }() + } + + wg.Wait() + + res, err := m.Scan(ctx, driver.ScanInput{Table: "users", Limit: raceGoroutines * 2}) + require.NoError(t, err) + assert.Equal(t, raceGoroutines, res.Count) +} + +// TestConcurrentTableMutationAndProjection alters the schema while readers +// project it, which is the path where a store pointer is mutated in place. +func TestConcurrentTableMutationAndProjection(t *testing.T) { + t.Parallel() + + m, _ := newMock(t) + ctx := context.Background() + createUsers(t, m) + + var wg sync.WaitGroup + + for i := range raceGoroutines { + wg.Add(4) + + go func() { + defer wg.Done() + + // Exactly one goroutine wins each column name; the rest see + // AlreadyExists, never a corrupted column list. + _, err := m.UpdateOCITable(ctx, "users", nosql.TableUpdate{ + DDLStatement: fmt.Sprintf("ALTER TABLE users (ADD c%d STRING)", i), + }) + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + _, err := m.CreateOCIIndex(ctx, "users", + nosql.IndexSpec{Name: fmt.Sprintf("i%d", i), Columns: []string{"name"}}, true) + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + table, err := m.GetOCITable(ctx, "users") + if assert.NoError(t, err) { + assert.NotEmpty(t, table.Schema.PrimaryKey) + } + }() + + go func() { + defer wg.Done() + + _, err := m.ListOCIIndexes(ctx, "users", "") + assert.NoError(t, err) + }() + } + + wg.Wait() + + table, err := m.GetOCITable(ctx, "users") + require.NoError(t, err) + assert.Len(t, table.Schema.Columns, 3+raceGoroutines) + + indexes, err := m.ListOCIIndexes(ctx, "users", "") + require.NoError(t, err) + assert.Len(t, indexes, raceGoroutines) +} + +// TestConcurrentQueryDelete runs the multi-delete path against concurrent +// writers; the deletes and the writes must not interleave inside one store. +func TestConcurrentQueryDelete(t *testing.T) { + t.Parallel() + + m, _ := newMock(t) + ctx := context.Background() + createUsers(t, m) + + var wg sync.WaitGroup + + for i := range raceGoroutines { + wg.Add(2) + + go func() { + defer wg.Done() + + _, err := m.PutOCIRow(ctx, "users", map[string]any{ + "id": float64(i), "email": "x@y.z", "name": "n", + }, "") + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + _, err := m.QueryOCI(ctx, compartmentA, fmt.Sprintf("DELETE FROM users WHERE id = %d", i), 0) + assert.NoError(t, err) + }() + } + + wg.Wait() + + _, err := m.QueryOCI(ctx, compartmentA, "SELECT * FROM users", 0) + assert.NoError(t, err) + assert.Equal(t, cerrors.OK, cerrors.GetCode(err)) +} diff --git a/providers/oci/nosql/row_extras.go b/providers/oci/nosql/row_extras.go new file mode 100644 index 000000000..c46459717 --- /dev/null +++ b/providers/oci/nosql/row_extras.go @@ -0,0 +1,296 @@ +package nosql + +import ( + "context" + "strconv" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// Write options UpdateRow accepts. +const ( + OptionIfAbsent = "IF_ABSENT" + OptionIfPresent = "IF_PRESENT" +) + +// GetOCIRow returns one row by primary key. The key arrives as the wire's +// column:value strings and is coerced to the column types the schema declares. +func (m *Mock) GetOCIRow(_ context.Context, nameOrID string, key map[string]string) (*Row, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + k, err := coerceKey(t, key) + if err != nil { + return nil, err + } + + item, ok := t.items.Get(itemKey(t, k)) + if !ok || m.expired(t, item) { + return nil, cerrors.New(cerrors.NotFound, "row not found") + } + + return toRow(item), nil +} + +// PutOCIRow writes a row. The option, when set, makes the write conditional on +// the row's absence or presence, as OCI's IF_ABSENT and IF_PRESENT do. +func (m *Mock) PutOCIRow(_ context.Context, nameOrID string, value map[string]any, option string) (*Row, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + row, err := coerceValue(t, value) + if err != nil { + return nil, err + } + + existing, present := t.items.Get(itemKey(t, row)) + present = present && !m.expired(t, existing) + + switch option { + case "": + case OptionIfAbsent: + if present { + return nil, cerrors.New(cerrors.FailedPrecondition, "row already exists and option is IF_ABSENT") + } + case OptionIfPresent: + if !present { + return nil, cerrors.New(cerrors.FailedPrecondition, "row does not exist and option is IF_PRESENT") + } + default: + return nil, cerrors.Newf(cerrors.InvalidArgument, "option %q is not IF_ABSENT or IF_PRESENT", option) + } + + return toRow(m.putRow(t, row)), nil +} + +// DeleteOCIRow removes one row, reporting whether it was there. OCI's +// DeleteRow answers 200 either way. +func (m *Mock) DeleteOCIRow(_ context.Context, nameOrID string, key map[string]string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return false, err + } + + k, err := coerceKey(t, key) + if err != nil { + return false, err + } + + stored := itemKey(t, k) + + item, ok := t.items.Get(stored) + if ok && m.expired(t, item) { + ok = false + } + + t.items.Delete(stored) + + return ok, nil +} + +// toRow projects a stored row onto the OCI shape, moving the internal expiry +// out of the value and into the metadata OCI reports it as. +func toRow(item map[string]any) *Row { + row := &Row{Value: visible(item)} + + if exp, ok := toUnix(item[ttlExpiryColumn]); ok && exp > 0 { + row.TimeOfExpiration = time.Unix(exp, 0).UTC().Format(timeFormat) + } + + return row +} + +// coerceKey turns the wire's column:value strings into a primary key, +// requiring every key column and refusing anything that is not one. +func coerceKey(t *tableData, key map[string]string) (map[string]any, error) { + if len(key) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "key is required") + } + + out := make(map[string]any, len(key)) + + for name, raw := range key { + if !isKeyColumn(t, name) { + return nil, cerrors.Newf(cerrors.InvalidArgument, "%q is not a primary key column of table %q", name, t.Name) + } + + col := t.Schema.Columns[columnIndex(t, name)] + + v, err := parseTyped(&col, raw) + if err != nil { + return nil, err + } + + out[name] = v + } + + for _, k := range t.Schema.PrimaryKey { + if _, ok := out[k]; !ok { + return nil, cerrors.Newf(cerrors.InvalidArgument, "key column %q is missing", k) + } + } + + return out, nil +} + +// coerceValue validates a row against the schema: every column must be +// declared, every primary key column present, and every value must fit its +// column's type. A missing nullable column takes its default when it has one. +func coerceValue(t *tableData, value map[string]any) (map[string]any, error) { + out := make(map[string]any, len(value)) + + for name, v := range value { + i := columnIndex(t, name) + if i < 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "column %q is not declared on table %q", name, t.Name) + } + + typed, err := convertTyped(&t.Schema.Columns[i], v) + if err != nil { + return nil, err + } + + out[name] = typed + } + + for i := range t.Schema.Columns { + col := &t.Schema.Columns[i] + if _, ok := out[col.Name]; ok { + continue + } + + if err := applyMissingColumn(t, col, out); err != nil { + return nil, err + } + } + + return out, nil +} + +// applyMissingColumn fills a column the caller left out, or reports why it +// cannot be left out. +func applyMissingColumn(t *tableData, col *Column, out map[string]any) error { + if isKeyColumn(t, col.Name) { + return cerrors.Newf(cerrors.InvalidArgument, "primary key column %q is missing from the row", col.Name) + } + + if col.DefaultValue != "" { + v, err := parseTyped(col, col.DefaultValue) + if err != nil { + return err + } + + out[col.Name] = v + + return nil + } + + if !col.IsNullable { + return cerrors.Newf(cerrors.InvalidArgument, "column %q is NOT NULL and has no default", col.Name) + } + + return nil +} + +// parseTyped reads a column value from its string form, which is how key +// values and DDL defaults arrive. +func parseTyped(col *Column, raw string) (any, error) { + switch col.Type { + case typeInteger, typeLong: + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, typeError(col, raw) + } + + return n, nil + case typeFloat, typeDouble, typeNumber: + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, typeError(col, raw) + } + + return f, nil + case typeBoolean: + b, err := strconv.ParseBool(raw) + if err != nil { + return nil, typeError(col, raw) + } + + return b, nil + } + + return raw, nil +} + +// convertTyped fits a decoded JSON value to its column's type. JSON has one +// number type, so an integer column takes a whole float64 and refuses a +// fractional one. +func convertTyped(col *Column, v any) (any, error) { + if v == nil { + if !col.IsNullable { + return nil, cerrors.Newf(cerrors.InvalidArgument, "column %q is NOT NULL", col.Name) + } + + return nil, nil //nolint:nilnil // a null column value is the value + } + + switch col.Type { + case typeInteger, typeLong: + return toInteger(col, v) + case typeFloat, typeDouble, typeNumber: + f, ok := v.(float64) + if !ok { + return nil, typeError(col, v) + } + + return f, nil + case typeBoolean: + b, ok := v.(bool) + if !ok { + return nil, typeError(col, v) + } + + return b, nil + case typeString, typeBinary, typeTimestamp: + s, ok := v.(string) + if !ok { + return nil, typeError(col, v) + } + + return s, nil + } + + // JSON columns hold whatever the caller sent. + return v, nil +} + +func toInteger(col *Column, v any) (any, error) { + f, ok := v.(float64) + if !ok { + return nil, typeError(col, v) + } + + if f != float64(int64(f)) { + return nil, typeError(col, v) + } + + return int64(f), nil +} + +func typeError(col *Column, v any) error { + return cerrors.Newf(cerrors.InvalidArgument, "value %v does not fit column %q of type %s", v, col.Name, col.Type) +} diff --git a/providers/oci/nosql/rows.go b/providers/oci/nosql/rows.go new file mode 100644 index 000000000..541dd5f6d --- /dev/null +++ b/providers/oci/nosql/rows.go @@ -0,0 +1,424 @@ +package nosql + +import ( + "context" + "fmt" + "maps" + "strconv" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// Filter and key-condition operators. +const ( + OpEqual = "=" + OpNotEqual = "!=" + OpLessThan = "<" + OpGreaterThan = ">" + OpLessEqual = "<=" + OpGreaterEqual = ">=" + OpContains = "CONTAINS" + OpBeginsWith = "BEGINS_WITH" + OpBetween = "BETWEEN" +) + +// PutItem writes a row, replacing any row with the same primary key. +func (m *Mock) PutItem(_ context.Context, table string, item map[string]any) error { + m.mu.Lock() + + t, err := m.lookup(table) + if err != nil { + m.mu.Unlock() + return err + } + + m.putRow(t, item) + m.mu.Unlock() + + m.emitMetric("WriteUnits", 1, table) + + return nil +} + +// putRow stores a row and stamps the table-level TTL expiry on it. Callers +// must hold m.mu. +func (m *Mock) putRow(t *tableData, item map[string]any) map[string]any { + stored := maps.Clone(item) + delete(stored, ttlExpiryColumn) + + if exp := m.expiryOf(t); exp > 0 { + stored[ttlExpiryColumn] = exp + } + + t.items.Set(itemKey(t, stored), stored) + + return stored +} + +// expiryOf returns the Unix time a row written now expires at, or zero when +// the table declares no TTL. +func (m *Mock) expiryOf(t *tableData) int64 { + if t.Schema.TTL.Days <= 0 { + return 0 + } + + const hoursPerDay = 24 + + return m.opts.Clock.Now().Add(time.Duration(t.Schema.TTL.Days*hoursPerDay) * time.Hour).Unix() +} + +// GetItem returns a row by primary key. +func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map[string]any, error) { + m.mu.RLock() + + t, err := m.lookup(table) + if err != nil { + m.mu.RUnlock() + return nil, err + } + + item, ok := t.items.Get(itemKey(t, key)) + expired := ok && m.expired(t, item) + + if ok && !expired { + item = visible(item) + } + + m.mu.RUnlock() + + if !ok || expired { + return nil, cerrors.New(cerrors.NotFound, "row not found") + } + + m.emitMetric("ReadUnits", 1, table) + + return item, nil +} + +// UpdateItem applies field-level updates to an existing row. +func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(input.Table) + if err != nil { + return nil, err + } + + k := itemKey(t, input.Key) + + item, ok := t.items.Get(k) + if !ok || m.expired(t, item) { + return nil, cerrors.New(cerrors.NotFound, "row not found") + } + + updated := maps.Clone(item) + + for _, a := range input.Actions { + switch a.Action { + case "SET": + updated[a.Field] = a.Value + case "REMOVE": + delete(updated, a.Field) + default: + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported update action %q", a.Action) + } + } + + t.items.Set(k, updated) + + return visible(updated), nil +} + +// DeleteItem removes a row by primary key. Deleting a row that is not there +// is not an error, matching OCI's DeleteRow. +func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) error { + m.mu.Lock() + + t, err := m.lookup(table) + if err != nil { + m.mu.Unlock() + return err + } + + t.items.Delete(itemKey(t, key)) + m.mu.Unlock() + + m.emitMetric("WriteUnits", 1, table) + + return nil +} + +// Query returns the rows sharing a shard key value, optionally narrowed by a +// sort key condition and post-conditions. +// +//nolint:gocritic // hugeParam: the driver interface fixes the signature. +func (m *Mock) Query(_ context.Context, input driver.QueryInput) (*driver.QueryResult, error) { + m.mu.RLock() + + t, err := m.lookup(input.Table) + if err != nil { + m.mu.RUnlock() + return nil, err + } + + pkField, skField, err := indexKeys(t, input.IndexName) + if err != nil { + m.mu.RUnlock() + return nil, err + } + + var matched []map[string]any + + for _, item := range m.liveItems(t) { + if !matchesKeyCondition(item, pkField, skField, &input.KeyCondition) { + continue + } + + if matchesFilters(item, input.Filters) { + matched = append(matched, item) + } + } + + keyFields := []string{t.Schema.ShardKey[0], sortKeyOf(&t.Schema)} + if input.IndexName != "" { + keyFields = append(keyFields, pkField, skField) + } + + identity := identityFunc(t) + m.mu.RUnlock() + + res, err := driver.PageOrdered(matched, pkField, skField, keyFields, + pageLimit(input.Limit), input.PageToken, input.ExclusiveStartKey, input.SortDescending, identity) + if err != nil { + return nil, err + } + + m.emitMetric("ReadUnits", float64(len(res.Items)), input.Table) + + return res, nil +} + +// Scan returns every row, narrowed by filters. +func (m *Mock) Scan(_ context.Context, input driver.ScanInput) (*driver.QueryResult, error) { + m.mu.RLock() + + t, err := m.lookup(input.Table) + if err != nil { + m.mu.RUnlock() + return nil, err + } + + var matched []map[string]any + + for _, item := range m.liveItems(t) { + if matchesFilters(item, input.Filters) { + matched = append(matched, item) + } + } + + pk, sk := t.Schema.ShardKey[0], sortKeyOf(&t.Schema) + identity := identityFunc(t) + m.mu.RUnlock() + + res, err := driver.PageOrdered(matched, pk, sk, []string{pk, sk}, + pageLimit(input.Limit), input.PageToken, input.ExclusiveStartKey, false, identity) + if err != nil { + return nil, err + } + + m.emitMetric("ReadUnits", float64(len(res.Items)), input.Table) + + return res, nil +} + +// identityFunc returns a row-identity function safe to call after m.mu is +// released: it closes over the key columns rather than the table pointer. +func identityFunc(t *tableData) func(map[string]any) string { + pk, sk := t.Schema.ShardKey[0], sortKeyOf(&t.Schema) + + return func(item map[string]any) string { + key := fmt.Sprintf("%v", item[pk]) + if sk != "" { + key += ":" + fmt.Sprintf("%v", item[sk]) + } + + return key + } +} + +// indexKeys resolves the columns a query orders and filters on. Callers must +// hold m.mu. +func indexKeys(t *tableData, indexName string) (pkField, skField string, err error) { + if indexName == "" { + return t.Schema.ShardKey[0], sortKeyOf(&t.Schema), nil + } + + for _, idx := range t.Indexes { + if idx.Name != indexName { + continue + } + + cfg := toGSIConfig(idx) + + return cfg.PartitionKey, cfg.SortKey, nil + } + + return "", "", cerrors.Newf(cerrors.NotFound, "index %q not found on table %q", indexName, t.Name) +} + +func pageLimit(limit int) int { + if limit <= 0 { + return defaultPageLimit + } + + return limit +} + +func matchesKeyCondition(item map[string]any, pkField, skField string, cond *driver.KeyCondition) bool { + if fmt.Sprintf("%v", item[pkField]) != fmt.Sprintf("%v", cond.PartitionVal) { + return false + } + + if cond.SortOp == "" || skField == "" { + return true + } + + return compareOp(fmt.Sprintf("%v", item[skField]), cond.SortOp, + fmt.Sprintf("%v", cond.SortVal), fmt.Sprintf("%v", cond.SortValEnd)) +} + +func matchesFilters(item map[string]any, filters []driver.ScanFilter) bool { + for _, f := range filters { + if !compareOp(fmt.Sprintf("%v", item[f.Field]), f.Op, fmt.Sprintf("%v", f.Value), "") { + return false + } + } + + return true +} + +// compareOp applies one comparison, ordering numerically when both sides parse +// as numbers and lexically otherwise. +func compareOp(val, op, want, end string) bool { + switch op { + case OpEqual: + return val == want + case OpNotEqual: + return val != want + case OpContains: + return strings.Contains(val, want) + case OpBeginsWith: + return strings.HasPrefix(val, want) + case OpBetween: + return compareStrings(val, want) >= 0 && compareStrings(val, end) <= 0 + } + + return compareOrdering(val, op, want) +} + +// compareOrdering applies the four relational operators. +func compareOrdering(val, op, want string) bool { + c := compareStrings(val, want) + + switch op { + case OpLessThan: + return c < 0 + case OpGreaterThan: + return c > 0 + case OpLessEqual: + return c <= 0 + case OpGreaterEqual: + return c >= 0 + } + + return false +} + +func compareStrings(a, b string) int { + fa, errA := strconv.ParseFloat(a, 64) + fb, errB := strconv.ParseFloat(b, 64) + + if errA == nil && errB == nil { + switch { + case fa < fb: + return -1 + case fa > fb: + return 1 + } + + return 0 + } + + return strings.Compare(a, b) +} + +// BatchPutItems writes several rows. +func (m *Mock) BatchPutItems(_ context.Context, table string, items []map[string]any) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return err + } + + for _, item := range items { + m.putRow(t, item) + } + + return nil +} + +// BatchGetItems returns the rows present for the given keys, skipping the +// ones that are missing or expired. +func (m *Mock) BatchGetItems(_ context.Context, table string, keys []map[string]any) ([]map[string]any, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.lookup(table) + if err != nil { + return nil, err + } + + var out []map[string]any + + for _, key := range keys { + item, ok := t.items.Get(itemKey(t, key)) + if !ok || m.expired(t, item) { + continue + } + + out = append(out, visible(item)) + } + + return out, nil +} + +// TransactWriteItems applies puts and deletes together. Every CloudEmu +// mutation is synchronous and the whole batch runs under one lock, so the +// group is atomic with respect to other callers. +func (m *Mock) TransactWriteItems( + _ context.Context, table string, puts []map[string]any, deletes []map[string]any, +) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.lookup(table) + if err != nil { + return err + } + + for _, item := range puts { + m.putRow(t, item) + } + + for _, key := range deletes { + t.items.Delete(itemKey(t, key)) + } + + return nil +} diff --git a/providers/oci/nosql/table_extras.go b/providers/oci/nosql/table_extras.go new file mode 100644 index 000000000..25ed644f0 --- /dev/null +++ b/providers/oci/nosql/table_extras.go @@ -0,0 +1,280 @@ +package nosql + +import ( + "context" + "maps" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// CreateOCITable creates a table from a CREATE TABLE statement. The compartment +// the caller names is recorded on the table and every list filters by it. +// +//nolint:gocritic // hugeParam: TableSpec mirrors OCI's CreateTableDetails and is passed by value like it. +func (m *Mock) CreateOCITable(_ context.Context, spec TableSpec) (*Table, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if spec.CompartmentID == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + d, err := ParseDDL(spec.DDLStatement) + if err != nil { + return nil, err + } + + if d.Kind != DDLCreateTable { + return nil, cerrors.Newf(cerrors.InvalidArgument, "CreateTable takes a CREATE TABLE statement, got %s", d.Kind) + } + + limits, err := normaliseLimits(spec.Limits) + if err != nil { + return nil, err + } + + if existing, ok := m.tables.Get(d.Table); ok { + if d.IfNotExists { + return ptr(toTable(existing)), nil + } + + return nil, cerrors.Newf(cerrors.AlreadyExists, "table %q already exists", d.Table) + } + + t, err := m.newTable(d.Table, normaliseStatement(spec.DDLStatement), &d.Schema, limits) + if err != nil { + return nil, err + } + + t.Scope = scope.Scope{Compartment: spec.CompartmentID} + t.IsAutoReclaimable = spec.IsAutoReclaimable + t.Tags = maps.Clone(spec.FreeformTags) + + return ptr(toTable(t)), nil +} + +// normaliseLimits validates a table's capacity against its mode: an on-demand +// table sets no read or write units, a provisioned one must set both. +func normaliseLimits(l TableLimits) (TableLimits, error) { + if l.CapacityMode == "" { + l.CapacityMode = CapacityProvisioned + } + + if l.MaxStorageInGBs < 0 { + return l, cerrors.New(cerrors.InvalidArgument, "maxStorageInGBs must not be negative") + } + + switch l.CapacityMode { + case CapacityOnDemand: + if l.MaxReadUnits != 0 || l.MaxWriteUnits != 0 { + return l, cerrors.New(cerrors.InvalidArgument, + "an ON_DEMAND table sets no maxReadUnits or maxWriteUnits") + } + + return l, nil + case CapacityProvisioned: + if l.MaxReadUnits <= 0 || l.MaxWriteUnits <= 0 { + return l, cerrors.New(cerrors.InvalidArgument, + "a PROVISIONED table sets maxReadUnits and maxWriteUnits above zero") + } + + return l, nil + } + + return l, cerrors.Newf(cerrors.InvalidArgument, "capacityMode %q is not ON_DEMAND or PROVISIONED", l.CapacityMode) +} + +// GetOCITable returns a table by name or OCID. +func (m *Mock) GetOCITable(_ context.Context, nameOrID string) (*Table, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + return ptr(toTable(t)), nil +} + +// ListOCITables returns the tables in a compartment, ordered by name. A +// non-empty name narrows the listing to that one table, as OCI's name query +// parameter does. +func (m *Mock) ListOCITables(_ context.Context, compartmentID, name string) ([]Table, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + filter := scope.Scope{Compartment: compartmentID} + names := m.tables.Keys() + sort.Strings(names) + + out := make([]Table, 0, len(names)) + + for _, n := range names { + t, ok := m.tables.Get(n) + if !ok || !t.Scope.Matches(filter) { + continue + } + + if name != "" && t.Name != name { + continue + } + + out = append(out, toTable(t)) + } + + return out, nil +} + +// UpdateOCITable applies an ALTER TABLE statement, new limits, tags and the +// auto-reclaim flag. Every field is optional, as UpdateTable's are. +func (m *Mock) UpdateOCITable(_ context.Context, nameOrID string, upd TableUpdate) (*Table, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return nil, err + } + + if upd.DDLStatement != "" { + if err := applyAlter(t, upd.DDLStatement); err != nil { + return nil, err + } + } + + if upd.Limits != nil { + limits, err := normaliseLimits(*upd.Limits) + if err != nil { + return nil, err + } + + t.Limits = limits + } + + if upd.IsAutoReclaimable != nil { + t.IsAutoReclaimable = *upd.IsAutoReclaimable + } + + if upd.FreeformTags != nil { + t.Tags = maps.Clone(upd.FreeformTags) + } + + t.TimeUpdated = m.now() + + return ptr(toTable(t)), nil +} + +// applyAlter applies one ALTER TABLE statement to a table. Callers must hold m.mu. +func applyAlter(t *tableData, statement string) error { + d, err := ParseDDL(statement) + if err != nil { + return err + } + + if d.Kind != DDLAlterTable { + return cerrors.Newf(cerrors.InvalidArgument, "UpdateTable takes an ALTER TABLE statement, got %s", d.Kind) + } + + if d.Table != t.Name { + return cerrors.Newf(cerrors.InvalidArgument, "ALTER TABLE names table %q, not %q", d.Table, t.Name) + } + + if err := applyAlterColumns(t, &d.Alter); err != nil { + return err + } + + if d.Alter.TTL != nil { + t.Schema.TTL = *d.Alter.TTL + } + + t.DDLStatement = ddlFromSchema(t.Name, &t.Schema) + + return nil +} + +// applyAlterColumns adds and drops columns, refusing to touch a key column or +// to add one that is already declared. +func applyAlterColumns(t *tableData, spec *AlterSpec) error { + for _, c := range spec.AddColumns { + if columnIndex(t, c.Name) >= 0 { + return cerrors.Newf(cerrors.AlreadyExists, "column %q is already declared on table %q", c.Name, t.Name) + } + + t.Schema.Columns = append(t.Schema.Columns, c) + } + + for _, name := range spec.DropColumns { + if isKeyColumn(t, name) { + return cerrors.Newf(cerrors.InvalidArgument, "column %q is part of the primary key of table %q", name, t.Name) + } + + i := columnIndex(t, name) + if i < 0 { + return cerrors.Newf(cerrors.NotFound, "column %q is not declared on table %q", name, t.Name) + } + + t.Schema.Columns = append(t.Schema.Columns[:i], t.Schema.Columns[i+1:]...) + } + + return nil +} + +func columnIndex(t *tableData, name string) int { + for i, c := range t.Schema.Columns { + if c.Name == name { + return i + } + } + + return -1 +} + +func isKeyColumn(t *tableData, name string) bool { + for _, k := range t.Schema.PrimaryKey { + if k == name { + return true + } + } + + return false +} + +// DeleteOCITable drops a table addressed by name or OCID. +func (m *Mock) DeleteOCITable(_ context.Context, nameOrID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(nameOrID) + if err != nil { + return err + } + + return m.dropTable(t.Name) +} + +// ChangeOCITableCompartment moves a table into another compartment. +func (m *Mock) ChangeOCITableCompartment(_ context.Context, nameOrID, compartmentID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if compartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + t, err := m.resolve(nameOrID) + if err != nil { + return err + } + + t.Scope = scope.Scope{Compartment: compartmentID} + t.TimeUpdated = m.now() + + return nil +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/providers/oci/oci.go b/providers/oci/oci.go index 0036a9a58..ad70fa496 100644 --- a/providers/oci/oci.go +++ b/providers/oci/oci.go @@ -6,6 +6,7 @@ import ( "github.com/stackshy/cloudemu/v2/internal/snapshot" "github.com/stackshy/cloudemu/v2/providers/oci/identity" "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" + "github.com/stackshy/cloudemu/v2/providers/oci/nosql" vcnprovider "github.com/stackshy/cloudemu/v2/providers/oci/vcn" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" @@ -76,6 +77,7 @@ func New(opts ...config.Option) *Provider { } p.Identity = identity.New(o) p.VCN = vcnprovider.New(o) + p.NoSQL = nosql.New(o) p.Monitoring = monitoring.New(o) diff --git a/providers/oci/oci_test.go b/providers/oci/oci_test.go index 54f380c3b..a786b45bb 100644 --- a/providers/oci/oci_test.go +++ b/providers/oci/oci_test.go @@ -62,6 +62,12 @@ func TestVCNIsWired(t *testing.T) { require.NotNil(t, p.VCN, "the VCN slot is filled by the vcn mock") } +func TestNoSQLIsWired(t *testing.T) { + p := oci.New() + + require.NotNil(t, p.NoSQL, "the NoSQL slot is filled by the nosql mock") +} + // TestServiceSlotsAreNilOrLive keeps what the retired per-service-name test // asserted, without naming a service: a slot reads as nil until a branch fills // it. A typed nil in an interface field passes a != nil check and then panics, diff --git a/server/oci/nosql/handler.go b/server/oci/nosql/handler.go new file mode 100644 index 000000000..3bb03bc89 --- /dev/null +++ b/server/oci/nosql/handler.go @@ -0,0 +1,311 @@ +// Package nosql implements OCI NoSQL Database Cloud Service's REST API +// against a CloudEmu database driver. Real github.com/oracle/oci-go-sdk nosql +// clients hit this handler the same way they hit +// nosql..oci.oraclecloud.com. +// +// Supported operations, all under the /20190828 API version: +// +// POST /tables — CreateTable +// GET /tables — ListTables +// GET/PUT/DELETE /tables/{tableNameOrId} — Get, Update, Delete +// POST /tables/{tableNameOrId}/actions/changeCompartment +// POST/GET /tables/{tableNameOrId}/indexes — CreateIndex, ListIndexes +// GET/DELETE /tables/{tableNameOrId}/indexes/{name} — GetIndex, DeleteIndex +// GET/PUT/DELETE /tables/{tableNameOrId}/rows — GetRow, UpdateRow, DeleteRow +// POST /query — Query +// +// Table and index mutations are asynchronous in real OCI, so they answer 202 +// with an opc-work-request-id the shared poller resolves; row writes are +// synchronous and answer 200. +// +// Not emulated, and answered with a 501 naming the gap rather than a bare +// 404: /tables/{id}/usage, which reports throughput consumption CloudEmu does +// not meter, and /query/prepare and /query/summarize, which hand back a +// prepared-statement handle the mock has no plan to bind it to. OCI NoSQL +// publishes no change stream, so there is no DynamoDB-Streams equivalent to +// serve. Tables report ACTIVE from the moment they are created: every +// CloudEmu mutation is synchronous, so the CREATING and DELETING states an +// SDK waiter may poll for are never observable. +package nosql + +import ( + "context" + "net/http" + "strconv" + "strings" + + nosqlprovider "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// apiVersion is the NoSQL Database API version, distinct from every other +// OCI service's. +const apiVersion = "20190828" + +// Collections and sub-collections this handler claims. +const ( + segTables = "tables" + segQuery = "query" + subIndexes = "indexes" + subRows = "rows" + subActions = "actions" + subUsage = "usage" + + actionChangeCompartment = "changeCompartment" + queryPrepare = "prepare" + querySummarize = "summarize" +) + +// Work request operation types the asynchronous mutations record. +const ( + opCreateTable = "CREATE_TABLE" + opUpdateTable = "UPDATE_TABLE" + opDeleteTable = "DELETE_TABLE" + opChangeCompartment = "CHANGE_TABLE_COMPARTMENT" + opCreateIndex = "CREATE_INDEX" + opDeleteIndex = "DELETE_INDEX" +) + +// Entity types a work request reports against. +const ( + entityTable = "table" + entityIndex = "index" +) + +// OCI error codes the handler raises itself. +const ( + codeInvalidParameter = "InvalidParameter" + codeMethodNotAllowed = "MethodNotAllowed" + codeNotImplemented = "NotImplemented" + codeNotFound = "NotAuthorizedOrNotFound" +) + +// maxPathSegments is /{version}/tables/{id}/{sub}/{name}. +const maxPathSegments = 5 + +// Extras is the OCI-only surface the portable database driver cannot express: +// tables are created from a DDL statement rather than a key list, carry an +// OCID, a compartment and capacity limits, and rows are addressed by typed +// primary key columns. *providers/oci/nosql.Mock satisfies it; any driver +// that does not is served 501 for every path this handler claims. +type Extras interface { + CreateOCITable(ctx context.Context, spec nosqlprovider.TableSpec) (*nosqlprovider.Table, error) + GetOCITable(ctx context.Context, nameOrID string) (*nosqlprovider.Table, error) + ListOCITables(ctx context.Context, compartmentID, name string) ([]nosqlprovider.Table, error) + UpdateOCITable( + ctx context.Context, nameOrID string, upd nosqlprovider.TableUpdate, + ) (*nosqlprovider.Table, error) + DeleteOCITable(ctx context.Context, nameOrID string) error + ChangeOCITableCompartment(ctx context.Context, nameOrID, compartmentID string) error + + CreateOCIIndex( + ctx context.Context, nameOrID string, spec nosqlprovider.IndexSpec, ifNotExists bool, + ) (*nosqlprovider.Index, error) + GetOCIIndex(ctx context.Context, nameOrID, indexName string) (*nosqlprovider.Index, error) + ListOCIIndexes(ctx context.Context, nameOrID, indexName string) ([]nosqlprovider.Index, error) + DeleteOCIIndex(ctx context.Context, nameOrID, indexName string, ifExists bool) error + + GetOCIRow(ctx context.Context, nameOrID string, key map[string]string) (*nosqlprovider.Row, error) + PutOCIRow( + ctx context.Context, nameOrID string, value map[string]any, option string, + ) (*nosqlprovider.Row, error) + DeleteOCIRow(ctx context.Context, nameOrID string, key map[string]string) (bool, error) + QueryOCI(ctx context.Context, compartmentID, statement string, limit int) ([]map[string]any, error) + + OCITableScope(nameOrID string) string +} + +// Handler serves OCI NoSQL Database against a database driver. +type Handler struct { + extras Extras + work *workrequest.Store +} + +// New returns a NoSQL handler. work records the asynchronous table and index +// mutations; a nil store leaves those paths unserved. +func New(db dbdriver.Database, work *workrequest.Store) *Handler { + extras, _ := db.(Extras) + + return &Handler{extras: extras, work: work} +} + +// route is a parsed NoSQL path. +type route struct { + Collection string + ID string + Sub string + Name string +} + +// Matches claims /20190828/tables and /20190828/query, and nothing else. The +// shared work request poller keeps /20190828/workRequests, which this handler +// deliberately leaves alone. +func (*Handler) Matches(r *http.Request) bool { + rt, ok := parsePath(r.URL.Path) + if !ok { + return false + } + + return rt.Collection == segTables || rt.Collection == segQuery +} + +// ServeHTTP routes on collection, then on path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rt, ok := parsePath(r.URL.Path) + if !ok { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "malformed NoSQL path") + return + } + + if h.extras == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, + "the wired database driver does not implement OCI NoSQL tables") + + return + } + + if rt.Collection == segQuery { + h.serveQuery(w, r, rt) + return + } + + h.serveTables(w, r, rt) +} + +// serveTables dispatches the table collection and everything hanging off one +// table. +func (h *Handler) serveTables(w http.ResponseWriter, r *http.Request, rt route) { + if rt.ID == "" { + switch r.Method { + case http.MethodPost: + h.createTable(w, r) + case http.MethodGet: + h.listTables(w, r) + default: + methodNotAllowed(w, r) + } + + return + } + + switch rt.Sub { + case "": + h.serveTable(w, r, rt.ID) + case subIndexes: + h.serveIndexes(w, r, rt) + case subRows: + h.serveRows(w, r, rt.ID) + case subActions: + h.serveTableAction(w, r, rt) + case subUsage: + unemulated(w, r, "table usage", + "CloudEmu does not meter read, write and storage consumption") + default: + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown sub-collection "+rt.Sub) + } +} + +func (h *Handler) serveTable(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getTable(w, r, id) + case http.MethodPut: + h.updateTable(w, r, id) + case http.MethodDelete: + h.deleteTable(w, r, id) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveTableAction(w http.ResponseWriter, r *http.Request, rt route) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + if rt.Name != actionChangeCompartment { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown action "+rt.Name) + return + } + + h.changeCompartment(w, r, rt.ID) +} + +// unemulated reports a path the handler claims but cannot serve, naming why. +func unemulated(w http.ResponseWriter, r *http.Request, what, why string) { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, what+" is not emulated: "+why) +} + +func methodNotAllowed(w http.ResponseWriter, r *http.Request) { + ocirest.WriteError(w, r, http.StatusMethodNotAllowed, codeMethodNotAllowed, "method not allowed") +} + +// accepted records a work request for an asynchronous mutation and answers +// 202, which is what an SDK waiter polls on. +func (h *Handler) accepted(w http.ResponseWriter, r *http.Request, operation, compartmentID string, res workrequest.Resource) { + id := h.work.Accept(operation, compartmentID, res) + + ocirest.SetWorkRequestID(w, id) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +// requireWorkRequests reports whether the asynchronous paths can be served. +func (h *Handler) requireWorkRequests(w http.ResponseWriter, r *http.Request) bool { + if h.work == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, "work requests are not configured") + return false + } + + return true +} + +// parsePath splits /{version}/{collection}[/{id}[/{sub}[/{name}]]]. +func parsePath(urlPath string) (route, bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) < 2 || len(parts) > maxPathSegments || parts[0] != apiVersion { + return route{}, false + } + + rt := route{Collection: parts[1]} + + if len(parts) > 2 { //nolint:mnd // the id follows the collection + rt.ID = parts[2] + } + + if len(parts) > 3 { //nolint:mnd // then the sub-collection + rt.Sub = parts[3] + } + + if len(parts) > 4 { //nolint:mnd // then the name or action within it + rt.Name = parts[4] + } + + return rt, true +} + +// paginate applies OCI's limit and opaque page cursor, stamping the cursor for +// the next page. The cursor is the offset the next page starts at. +func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) []T { + start := 0 + + if token := ocirest.Page(r); token != "" { + if n, err := strconv.Atoi(token); err == nil && n > 0 { + start = n + } + } + + // items[:0] rather than nil: an empty page is [] on the wire, not null. + if start >= len(items) { + return items[:0] + } + + end := min(start+ocirest.Limit(r), len(items)) + if end < len(items) { + ocirest.SetNextPage(w, strconv.Itoa(end)) + } + + return items[start:end] +} diff --git a/server/oci/nosql/handler_test.go b/server/oci/nosql/handler_test.go new file mode 100644 index 000000000..3e4f9e865 --- /dev/null +++ b/server/oci/nosql/handler_test.go @@ -0,0 +1,739 @@ +package nosql_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + nosqlprovider "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + ocinosql "github.com/stackshy/cloudemu/v2/server/oci/nosql" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// The OCI-only capability is declared consumer-side, so the compile-time check +// that the mock satisfies it lives here, where the import direction allows it. +var _ ocinosql.Extras = (*nosqlprovider.Mock)(nil) + +const ( + compartmentA = "ocid1.compartment.oc1..aaaa" + compartmentB = "ocid1.compartment.oc1..bbbb" + + usersDDL = "CREATE TABLE users (id INTEGER, email STRING, name STRING, PRIMARY KEY (SHARD(id), email))" +) + +func newHandler(t *testing.T) (*ocinosql.Handler, *nosqlprovider.Mock) { + t.Helper() + + opts := config.NewOptions( + config.WithClock(config.NewFakeClock(time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC))), + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(compartmentA), + ) + mock := nosqlprovider.New(opts) + + return ocinosql.New(mock, workrequest.New(opts)), mock +} + +func do(t *testing.T, h http.Handler, method, target string, body any) *httptest.ResponseRecorder { + t.Helper() + + var reader io.Reader + + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + + reader = strings.NewReader(string(raw)) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(method, target, reader)) + + return rec +} + +// createTable puts a users table in compartmentA and returns its OCID. +func createTable(t *testing.T, h http.Handler) string { + t.Helper() + + rec := do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "compartmentId": compartmentA, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{ + "maxReadUnits": 50, "maxWriteUnits": 50, "maxStorageInGBs": 1, "capacityMode": "PROVISIONED", + }, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + + got := do(t, h, http.MethodGet, "/20190828/tables/users", nil) + require.Equal(t, http.StatusOK, got.Code) + + var table struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(got.Body.Bytes(), &table)) + + return table.ID +} + +// TestMatches pins what the handler claims and, importantly, what it must not: +// handlers are first-match-wins, so a broad predicate swallows another +// service's traffic. +func TestMatches(t *testing.T) { + h, _ := newHandler(t) + + tests := []struct { + name string + path string + expect bool + }{ + {name: "table collection", path: "/20190828/tables", expect: true}, + {name: "single table", path: "/20190828/tables/users", expect: true}, + {name: "rows", path: "/20190828/tables/users/rows", expect: true}, + {name: "indexes", path: "/20190828/tables/users/indexes", expect: true}, + {name: "single index", path: "/20190828/tables/users/indexes/byName", expect: true}, + {name: "change compartment action", path: "/20190828/tables/users/actions/changeCompartment", expect: true}, + {name: "query", path: "/20190828/query", expect: true}, + {name: "query prepare", path: "/20190828/query/prepare", expect: true}, + + {name: "the shared work request poller keeps its own path", path: "/20190828/workRequests", expect: false}, + {name: "a work request by id", path: "/20190828/workRequests/ocid1.workrequest.oc1.iad.a", expect: false}, + {name: "core networking tables are another api version", path: "/20160918/tables", expect: false}, + {name: "monitoring", path: "/20180401/metrics", expect: false}, + {name: "identity", path: "/20160918/users", expect: false}, + {name: "unversioned", path: "/tables", expect: false}, + {name: "root", path: "/", expect: false}, + {name: "too many segments", path: "/20190828/tables/users/indexes/byName/extra", expect: false}, + {name: "another collection under the nosql version", path: "/20190828/configuration", expect: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, h.Matches(httptest.NewRequest(http.MethodGet, tc.path, nil))) + }) + } +} + +// TestDriverWithoutExtrasIsNotImplemented covers the consumer-side capability +// contract: a database driver that is not the OCI mock gets a clean 501. +func TestDriverWithoutExtrasIsNotImplemented(t *testing.T) { + h := ocinosql.New(plainDriver{}, workrequest.New(config.NewOptions())) + + rec := do(t, h, http.MethodGet, "/20190828/tables?compartmentId="+compartmentA, nil) + + assert.Equal(t, http.StatusNotImplemented, rec.Code) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "NotImplemented", body.Code) + assert.Contains(t, body.Message, "does not implement OCI NoSQL tables") +} + +// TestCreateTableRecordsWorkRequest covers the asynchronous mutation contract: +// 202 plus an opc-work-request-id an SDK waiter can poll. +func TestCreateTableRecordsWorkRequest(t *testing.T) { + opts := config.NewOptions(config.WithRegion("us-ashburn-1"), config.WithCompartmentID(compartmentA)) + store := workrequest.New(opts) + h := ocinosql.New(nosqlprovider.New(opts), store) + + rec := do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "compartmentId": compartmentA, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}, + }) + + require.Equal(t, http.StatusAccepted, rec.Code) + + wrID := rec.Header().Get(ocirest.HeaderWorkRequestID) + require.NotEmpty(t, wrID) + + wr, ok := store.Get(wrID) + require.True(t, ok) + assert.Equal(t, "CREATE_TABLE", wr.OperationType) + assert.Equal(t, compartmentA, wr.CompartmentID) + require.Len(t, wr.Resources, 1) + assert.Equal(t, "table", wr.Resources[0].EntityType) + assert.Equal(t, workrequest.ActionCreated, wr.Resources[0].ActionType) + assert.Contains(t, wr.Resources[0].Identifier, "ocid1.nosqltable.") +} + +func TestAsyncMutationsStampWorkRequests(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + do(t, h, http.MethodPost, "/20190828/tables/users/indexes", map[string]any{ + "name": "byName", "keys": []map[string]string{{"columnName": "name"}}, + }) + + tests := []struct { + name string + method string + target string + body any + expectOp string + setupSkip bool + }{ + { + name: "update table", + method: http.MethodPut, + target: "/20190828/tables/users", + body: map[string]any{"ddlStatement": "ALTER TABLE users (ADD nickname STRING)"}, + expectOp: "UPDATE_TABLE", + }, + { + name: "change compartment", + method: http.MethodPost, + target: "/20190828/tables/users/actions/changeCompartment", + body: map[string]any{"toCompartmentId": compartmentB}, + expectOp: "CHANGE_TABLE_COMPARTMENT", + }, + { + name: "delete index", + method: http.MethodDelete, + target: "/20190828/tables/users/indexes/byName", + expectOp: "DELETE_INDEX", + }, + { + name: "delete table", + method: http.MethodDelete, + target: "/20190828/tables/users", + expectOp: "DELETE_TABLE", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.target, tc.body) + + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) + }) + } +} + +func TestCreateTableErrors(t *testing.T) { + tests := []struct { + name string + body any + expectStatus int + expectWord string + }{ + { + name: "missing compartment", + body: map[string]any{"ddlStatement": usersDDL, "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}}, + expectStatus: http.StatusBadRequest, + expectWord: "compartmentId is required", + }, + { + name: "missing limits", + body: map[string]any{"compartmentId": compartmentA, "ddlStatement": usersDDL}, + expectStatus: http.StatusBadRequest, + expectWord: "tableLimits is required", + }, + { + name: "unsupported ddl", + body: map[string]any{ + "compartmentId": compartmentA, + "ddlStatement": "TRUNCATE TABLE users", + "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}, + }, + expectStatus: http.StatusBadRequest, + expectWord: "unsupported DDL statement", + }, + { + name: "name disagrees with the ddl", + body: map[string]any{ + "name": "other", + "compartmentId": compartmentA, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}, + }, + expectStatus: http.StatusBadRequest, + expectWord: "does not match the table named by ddlStatement", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h, _ := newHandler(t) + + rec := do(t, h, http.MethodPost, "/20190828/tables", tc.body) + + require.Equal(t, tc.expectStatus, rec.Code) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body.Message, tc.expectWord) + }) + } +} + +// TestCreateTableNameMismatchLeavesNoTable checks the half-built table from a +// rejected name is not left behind. +func TestCreateTableNameMismatchLeavesNoTable(t *testing.T) { + h, _ := newHandler(t) + + rec := do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "name": "other", + "compartmentId": compartmentA, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + + got := do(t, h, http.MethodGet, "/20190828/tables/users", nil) + assert.Equal(t, http.StatusNotFound, got.Code) +} + +func TestListTablesRequiresCompartmentID(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, http.MethodGet, "/20190828/tables", nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body.Message, "compartmentId is required") +} + +func TestListTablesFiltersByCompartment(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + tests := []struct { + name string + compartment string + expectLen int + }{ + {name: "owning compartment", compartment: compartmentA, expectLen: 1}, + {name: "another compartment", compartment: compartmentB, expectLen: 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20190828/tables?compartmentId="+tc.compartment, nil) + + require.Equal(t, http.StatusOK, rec.Code) + + var coll struct { + Items []map[string]any `json:"items"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &coll)) + assert.Len(t, coll.Items, tc.expectLen) + }) + } +} + +// TestGetTableAcrossCompartmentsIs404 keeps a caller from probing for a table +// they cannot see: OCI returns the same NotAuthorizedOrNotFound either way. +func TestGetTableAcrossCompartmentsIs404(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, http.MethodGet, "/20190828/tables/users?compartmentId="+compartmentB, nil) + + require.Equal(t, http.StatusNotFound, rec.Code) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "NotAuthorizedOrNotFound", body.Code) +} + +func TestGetTableByOCID(t *testing.T) { + h, _ := newHandler(t) + id := createTable(t, h) + + rec := do(t, h, http.MethodGet, "/20190828/tables/"+id, nil) + + require.Equal(t, http.StatusOK, rec.Code) + + var table struct { + Name string `json:"name"` + Schema struct { + PrimaryKey []string `json:"primaryKey"` + ShardKey []string `json:"shardKey"` + TTL int `json:"ttl"` + } `json:"schema"` + LifecycleState string `json:"lifecycleState"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &table)) + assert.Equal(t, "users", table.Name) + assert.Equal(t, []string{"id", "email"}, table.Schema.PrimaryKey) + assert.Equal(t, []string{"id"}, table.Schema.ShardKey) + assert.Equal(t, "ACTIVE", table.LifecycleState) +} + +func TestRowRoundTripOverTheWire(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + put := do(t, h, http.MethodPut, "/20190828/tables/users/rows", map[string]any{ + "compartmentId": compartmentA, + "value": map[string]any{"id": 1, "email": "a@example.com", "name": "Ada"}, + }) + require.Equal(t, http.StatusOK, put.Code, put.Body.String()) + + key := "?compartmentId=" + compartmentA + "&key=id:1&key=" + url.QueryEscape("email:a@example.com") + + get := do(t, h, http.MethodGet, "/20190828/tables/users/rows"+key, nil) + require.Equal(t, http.StatusOK, get.Code) + + var row struct { + Value map[string]any `json:"value"` + TimeOfExpiration string `json:"timeOfExpiration"` + } + + require.NoError(t, json.Unmarshal(get.Body.Bytes(), &row)) + assert.Equal(t, "Ada", row.Value["name"]) + assert.Empty(t, row.TimeOfExpiration) + + del := do(t, h, http.MethodDelete, "/20190828/tables/users/rows"+key, nil) + require.Equal(t, http.StatusOK, del.Code) + assert.JSONEq(t, `{"isSuccess":true}`, del.Body.String()) + + missing := do(t, h, http.MethodGet, "/20190828/tables/users/rows"+key, nil) + assert.Equal(t, http.StatusNotFound, missing.Code) +} + +func TestRowErrors(t *testing.T) { + tests := []struct { + name string + method string + target string + body any + expectStatus int + expectWord string + }{ + { + name: "get without a key", + method: http.MethodGet, + target: "/20190828/tables/users/rows?compartmentId=" + compartmentA, + expectStatus: http.StatusBadRequest, + expectWord: "at least one key parameter is required", + }, + { + name: "malformed key pair", + method: http.MethodGet, + target: "/20190828/tables/users/rows?compartmentId=" + compartmentA + "&key=id", + expectStatus: http.StatusBadRequest, + expectWord: "is not a column:value pair", + }, + { + name: "put without a value", + method: http.MethodPut, + target: "/20190828/tables/users/rows", + body: map[string]any{"compartmentId": compartmentA}, + expectStatus: http.StatusBadRequest, + expectWord: "value is required", + }, + { + name: "put an undeclared column", + method: http.MethodPut, + target: "/20190828/tables/users/rows", + body: map[string]any{ + "compartmentId": compartmentA, + "value": map[string]any{"id": 1, "email": "a@b.c", "bogus": "x"}, + }, + expectStatus: http.StatusBadRequest, + expectWord: "is not declared", + }, + { + name: "put into a table in another compartment", + method: http.MethodPut, + target: "/20190828/tables/users/rows", + body: map[string]any{ + "compartmentId": compartmentB, + "value": map[string]any{"id": 1, "email": "a@b.c"}, + }, + expectStatus: http.StatusNotFound, + expectWord: "not found", + }, + { + name: "get from an unknown table", + method: http.MethodGet, + target: "/20190828/tables/nope/rows?compartmentId=" + compartmentA + "&key=id:1", + expectStatus: http.StatusNotFound, + expectWord: "not found", + }, + { + name: "unsupported verb", + method: http.MethodPost, + target: "/20190828/tables/users/rows", + expectStatus: http.StatusMethodNotAllowed, + expectWord: "method not allowed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, tc.method, tc.target, tc.body) + + require.Equal(t, tc.expectStatus, rec.Code, rec.Body.String()) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body.Message, tc.expectWord) + }) + } +} + +func TestIndexLifecycleOverTheWire(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + create := do(t, h, http.MethodPost, "/20190828/tables/users/indexes", map[string]any{ + "compartmentId": compartmentA, + "name": "byName", + "keys": []map[string]string{{"columnName": "name"}}, + }) + require.Equal(t, http.StatusAccepted, create.Code, create.Body.String()) + assert.NotEmpty(t, create.Header().Get(ocirest.HeaderWorkRequestID)) + + list := do(t, h, http.MethodGet, "/20190828/tables/users/indexes?compartmentId="+compartmentA, nil) + require.Equal(t, http.StatusOK, list.Code) + + var coll struct { + Items []struct { + Name string `json:"name"` + Keys []struct { + ColumnName string `json:"columnName"` + } `json:"keys"` + } `json:"items"` + } + + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &coll)) + require.Len(t, coll.Items, 1) + assert.Equal(t, "byName", coll.Items[0].Name) + require.Len(t, coll.Items[0].Keys, 1) + assert.Equal(t, "name", coll.Items[0].Keys[0].ColumnName) + + get := do(t, h, http.MethodGet, "/20190828/tables/users/indexes/byName", nil) + assert.Equal(t, http.StatusOK, get.Code) + + missing := do(t, h, http.MethodGet, "/20190828/tables/users/indexes/nope", nil) + assert.Equal(t, http.StatusNotFound, missing.Code) + + del := do(t, h, http.MethodDelete, "/20190828/tables/users/indexes/byName", nil) + assert.Equal(t, http.StatusAccepted, del.Code) + + again := do(t, h, http.MethodDelete, "/20190828/tables/users/indexes/byName", nil) + assert.Equal(t, http.StatusNotFound, again.Code) + + tolerant := do(t, h, http.MethodDelete, "/20190828/tables/users/indexes/byName?isIfExists=true", nil) + assert.Equal(t, http.StatusAccepted, tolerant.Code) +} + +func TestListIndexesRequiresCompartmentID(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, http.MethodGet, "/20190828/tables/users/indexes", nil) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestQueryOverTheWire(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + for _, email := range []string{"a@x.com", "b@x.com"} { + put := do(t, h, http.MethodPut, "/20190828/tables/users/rows", map[string]any{ + "compartmentId": compartmentA, + "value": map[string]any{"id": 1, "email": email, "name": "Ada"}, + }) + require.Equal(t, http.StatusOK, put.Code) + } + + sel := do(t, h, http.MethodPost, "/20190828/query", map[string]any{ + "compartmentId": compartmentA, "statement": "SELECT * FROM users", + }) + require.Equal(t, http.StatusOK, sel.Code) + + var coll struct { + Items []map[string]any `json:"items"` + } + + require.NoError(t, json.Unmarshal(sel.Body.Bytes(), &coll)) + assert.Len(t, coll.Items, 2) + + del := do(t, h, http.MethodPost, "/20190828/query", map[string]any{ + "compartmentId": compartmentA, "statement": "DELETE FROM users WHERE id = 1", + }) + require.Equal(t, http.StatusOK, del.Code) + + require.NoError(t, json.Unmarshal(del.Body.Bytes(), &coll)) + require.Len(t, coll.Items, 1) + assert.InDelta(t, 2.0, coll.Items[0]["NumRowsDeleted"], 0.001) +} + +func TestQueryErrors(t *testing.T) { + tests := []struct { + name string + method string + target string + body any + expectStatus int + expectWord string + }{ + { + name: "no compartment", + method: http.MethodPost, + target: "/20190828/query", + body: map[string]any{"statement": "SELECT * FROM users"}, + expectStatus: http.StatusBadRequest, + expectWord: "compartmentId is required", + }, + { + name: "no statement", + method: http.MethodPost, + target: "/20190828/query", + body: map[string]any{"compartmentId": compartmentA}, + expectStatus: http.StatusBadRequest, + expectWord: "statement is required", + }, + { + name: "unsupported statement", + method: http.MethodPost, + target: "/20190828/query", + body: map[string]any{"compartmentId": compartmentA, "statement": "SELECT name FROM users"}, + expectStatus: http.StatusBadRequest, + expectWord: "only SELECT * is supported", + }, + { + name: "prepare is not emulated", + method: http.MethodGet, + target: "/20190828/query/prepare?compartmentId=" + compartmentA, + expectStatus: http.StatusNotImplemented, + expectWord: "prepared-statement handle", + }, + { + name: "summarize is not emulated", + method: http.MethodPost, + target: "/20190828/query/summarize", + body: map[string]any{"compartmentId": compartmentA}, + expectStatus: http.StatusNotImplemented, + expectWord: "prepared-statement handle", + }, + { + name: "unknown query sub-resource", + method: http.MethodPost, + target: "/20190828/query/explain", + expectStatus: http.StatusNotFound, + expectWord: "unknown query sub-resource", + }, + { + name: "query is POST only", + method: http.MethodGet, + target: "/20190828/query", + expectStatus: http.StatusMethodNotAllowed, + expectWord: "method not allowed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, tc.method, tc.target, tc.body) + + require.Equal(t, tc.expectStatus, rec.Code, rec.Body.String()) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body.Message, tc.expectWord) + }) + } +} + +// TestUnemulatedPaths covers the paths the handler claims in order to report +// them, so a caller reaching for one is told why rather than left with a 404. +func TestUnemulatedPaths(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, http.MethodGet, "/20190828/tables/users/usage?compartmentId="+compartmentA, nil) + + require.Equal(t, http.StatusNotImplemented, rec.Code) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body.Message, "does not meter read, write and storage consumption") +} + +func TestRoutingErrors(t *testing.T) { + tests := []struct { + name string + method string + target string + expectStatus int + }{ + {name: "unknown sub-collection", method: http.MethodGet, target: "/20190828/tables/users/columns", expectStatus: http.StatusNotFound}, + {name: "unknown action", method: http.MethodPost, target: "/20190828/tables/users/actions/freeze", expectStatus: http.StatusNotFound}, + {name: "action must be POST", method: http.MethodGet, target: "/20190828/tables/users/actions/changeCompartment", expectStatus: http.StatusMethodNotAllowed}, + {name: "collection verb", method: http.MethodPatch, target: "/20190828/tables", expectStatus: http.StatusMethodNotAllowed}, + {name: "single table verb", method: http.MethodPatch, target: "/20190828/tables/users", expectStatus: http.StatusMethodNotAllowed}, + {name: "index collection verb", method: http.MethodPatch, target: "/20190828/tables/users/indexes", expectStatus: http.StatusMethodNotAllowed}, + {name: "single index verb", method: http.MethodPatch, target: "/20190828/tables/users/indexes/x", expectStatus: http.StatusMethodNotAllowed}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, tc.method, tc.target, nil) + + assert.Equal(t, tc.expectStatus, rec.Code, rec.Body.String()) + }) + } +} + +func TestChangeCompartmentRequiresDestination(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, http.MethodPost, "/20190828/tables/users/actions/changeCompartment", map[string]any{}) + + require.Equal(t, http.StatusBadRequest, rec.Code) + + var body ocirest.ErrorBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body.Message, "toCompartmentId is required") +} + +func TestWorkRequestsUnconfiguredIsNotImplemented(t *testing.T) { + h := ocinosql.New(nosqlprovider.New(config.NewOptions()), nil) + + rec := do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "compartmentId": compartmentA, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}, + }) + + assert.Equal(t, http.StatusNotImplemented, rec.Code) +} + +// plainDriver is a database driver that does not implement the OCI capability. +type plainDriver struct { + dbdriver.Database +} diff --git a/server/oci/nosql/index.go b/server/oci/nosql/index.go new file mode 100644 index 000000000..c3d3f0713 --- /dev/null +++ b/server/oci/nosql/index.go @@ -0,0 +1,141 @@ +package nosql + +import ( + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + nosqlprovider "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveIndexes routes the index collection and a single index within it. +func (h *Handler) serveIndexes(w http.ResponseWriter, r *http.Request, rt route) { + if rt.Name == "" { + switch r.Method { + case http.MethodPost: + h.createIndex(w, r, rt.ID) + case http.MethodGet: + h.listIndexes(w, r, rt.ID) + default: + methodNotAllowed(w, r) + } + + return + } + + switch r.Method { + case http.MethodGet: + h.getIndex(w, r, rt.ID, rt.Name) + case http.MethodDelete: + h.deleteIndex(w, r, rt.ID, rt.Name) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) createIndex(w http.ResponseWriter, r *http.Request, tableID string) { + if !h.requireWorkRequests(w, r) { + return + } + + var req createIndexRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + spec := nosqlprovider.IndexSpec{Name: req.Name} + for _, k := range req.Keys { + spec.Columns = append(spec.Columns, k.ColumnName) + } + + table, err := h.findTable(r, tableID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + if _, err := h.extras.CreateOCIIndex(r.Context(), tableID, spec, req.IsIfNotExists); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accepted(w, r, opCreateIndex, table.CompartmentID, workrequest.Resource{ + EntityType: entityIndex, + ActionType: workrequest.ActionCreated, + Identifier: table.ID, + }) +} + +// listIndexes returns a table's indexes. Real OCI marks compartmentId +// optional here; CloudEmu requires it so every list is compartment-scoped. +func (h *Handler) listIndexes(w http.ResponseWriter, r *http.Request, tableID string) { + if _, given := ocirest.RequireCompartmentID(w, r); !given { + return + } + + if _, err := h.findTable(r, tableID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + indexes, err := h.extras.ListOCIIndexes(r.Context(), tableID, r.URL.Query().Get("name")) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + items := make([]indexBody, 0, len(indexes)) + for i := range indexes { + items = append(items, toIndexBody(&indexes[i])) + } + + ocirest.WriteJSON(w, r, http.StatusOK, indexCollection{Items: paginate(w, r, items)}) +} + +func (h *Handler) getIndex(w http.ResponseWriter, r *http.Request, tableID, name string) { + if _, err := h.findTable(r, tableID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + idx, err := h.extras.GetOCIIndex(r.Context(), tableID, name) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toIndexBody(idx)) +} + +func (h *Handler) deleteIndex(w http.ResponseWriter, r *http.Request, tableID, name string) { + if !h.requireWorkRequests(w, r) { + return + } + + table, err := h.findTable(r, tableID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ifExists := r.URL.Query().Get("isIfExists") == "true" + + if err := h.extras.DeleteOCIIndex(r.Context(), tableID, name, ifExists); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accepted(w, r, opDeleteIndex, table.CompartmentID, workrequest.Resource{ + EntityType: entityIndex, + ActionType: workrequest.ActionDeleted, + Identifier: table.ID, + }) +} + +// notFound is the error a table hidden by a compartment filter reports, which +// WriteDriverError collapses into OCI's single NotAuthorizedOrNotFound. +func notFound(id string) error { + return cerrors.Newf(cerrors.NotFound, "table %q not found", id) +} diff --git a/server/oci/nosql/query.go b/server/oci/nosql/query.go new file mode 100644 index 000000000..78c563cbb --- /dev/null +++ b/server/oci/nosql/query.go @@ -0,0 +1,62 @@ +package nosql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveQuery routes /20190828/query and reports the statement endpoints +// CloudEmu does not serve. +func (h *Handler) serveQuery(w http.ResponseWriter, r *http.Request, rt route) { + switch rt.ID { + case "": + case queryPrepare, querySummarize: + unemulated(w, r, "query/"+rt.ID, + "CloudEmu has no prepared-statement handle to hand back or bind variables to") + + return + default: + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown query sub-resource "+rt.ID) + return + } + + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + h.runQuery(w, r) +} + +// runQuery executes one statement. OCI's REST API has no multi-delete +// operation, so DELETE FROM over this endpoint is how several rows go at once. +func (h *Handler) runQuery(w http.ResponseWriter, r *http.Request) { + var req queryRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if req.Statement == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "statement is required") + return + } + + items, err := h.extras.QueryOCI(r.Context(), req.CompartmentID, req.Statement, req.Limit) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + if items == nil { + items = []map[string]any{} + } + + ocirest.WriteJSON(w, r, http.StatusOK, queryResultCollection{Items: paginate(w, r, items)}) +} diff --git a/server/oci/nosql/row.go b/server/oci/nosql/row.go new file mode 100644 index 000000000..bf6dea6db --- /dev/null +++ b/server/oci/nosql/row.go @@ -0,0 +1,142 @@ +package nosql + +import ( + "net/http" + "net/url" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveRows routes the row sub-collection. Row writes are synchronous in real +// OCI, so none of them records a work request. +func (h *Handler) serveRows(w http.ResponseWriter, r *http.Request, tableID string) { + switch r.Method { + case http.MethodGet: + h.getRow(w, r, tableID) + case http.MethodPut: + h.putRow(w, r, tableID) + case http.MethodDelete: + h.deleteRow(w, r, tableID) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) getRow(w http.ResponseWriter, r *http.Request, tableID string) { + key, ok := decodeKey(w, r) + if !ok { + return + } + + if _, err := h.findTable(r, tableID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + row, err := h.extras.GetOCIRow(r.Context(), tableID, key) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, rowBody{Value: row.Value, TimeOfExpiration: row.TimeOfExpiration}) +} + +// putRow serves UpdateRow, OCI's upsert. Its result model carries no row +// value, so the response body is empty; the expiry a table TTL implies is +// observable through GetRow. +func (h *Handler) putRow(w http.ResponseWriter, r *http.Request, tableID string) { + var req updateRowRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if len(req.Value) == 0 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "value is required") + return + } + + if !h.rowCompartmentMatches(w, r, tableID, req.CompartmentID) { + return + } + + if _, err := h.extras.PutOCIRow(r.Context(), tableID, req.Value, req.Option); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, struct{}{}) +} + +func (h *Handler) deleteRow(w http.ResponseWriter, r *http.Request, tableID string) { + key, ok := decodeKey(w, r) + if !ok { + return + } + + if _, err := h.findTable(r, tableID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + deleted, err := h.extras.DeleteOCIRow(r.Context(), tableID, key) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, deleteRowResult{IsSuccess: deleted}) +} + +// rowCompartmentMatches checks a body-supplied compartmentId against the +// table's, the way the query-parameter form is checked on the read paths. +func (h *Handler) rowCompartmentMatches(w http.ResponseWriter, r *http.Request, tableID, compartmentID string) bool { + table, err := h.extras.GetOCITable(r.Context(), tableID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return false + } + + if compartmentID != "" && table.CompartmentID != compartmentID { + ocirest.WriteDriverError(w, r, notFound(tableID)) + return false + } + + return true +} + +// decodeKey reads OCI's repeated key parameter, each entry a "column:value" +// pair. A pair without a colon is refused rather than read as a bare column. +func decodeKey(w http.ResponseWriter, r *http.Request) (map[string]string, bool) { + raw := r.URL.Query()["key"] + if len(raw) == 0 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "at least one key parameter is required") + return nil, false + } + + key := make(map[string]string, len(raw)) + + for _, pair := range raw { + column, value, found := strings.Cut(pair, ":") + if !found || column == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "key "+pair+" is not a column:value pair") + + return nil, false + } + + decoded, err := url.QueryUnescape(value) + if err != nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "key "+pair+" is not valid percent-encoding") + + return nil, false + } + + key[column] = decoded + } + + return key, true +} diff --git a/server/oci/nosql/table.go b/server/oci/nosql/table.go new file mode 100644 index 000000000..246ed5056 --- /dev/null +++ b/server/oci/nosql/table.go @@ -0,0 +1,205 @@ +package nosql + +import ( + "net/http" + + nosqlprovider "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// createTable creates a table from its DDL statement. Real OCI runs it +// asynchronously, so the response is a 202 carrying the work request. +func (h *Handler) createTable(w http.ResponseWriter, r *http.Request) { + if !h.requireWorkRequests(w, r) { + return + } + + var req createTableRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if req.TableLimits == nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "tableLimits is required") + return + } + + spec := nosqlprovider.TableSpec{ + CompartmentID: req.CompartmentID, + DDLStatement: req.DDLStatement, + Limits: fromLimitsBody(req.TableLimits), + IsAutoReclaimable: req.IsAutoReclaimable, + FreeformTags: req.FreeformTags, + } + + table, err := h.extras.CreateOCITable(r.Context(), spec) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + // CreateTableDetails carries a name alongside the DDL; the two must agree, + // since the DDL is what actually names the table. + if req.Name != "" && req.Name != table.Name { + _ = h.extras.DeleteOCITable(r.Context(), table.Name) + + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "name "+req.Name+" does not match the table named by ddlStatement, "+table.Name) + + return + } + + h.accepted(w, r, opCreateTable, table.CompartmentID, workrequest.Resource{ + EntityType: entityTable, + ActionType: workrequest.ActionCreated, + Identifier: table.ID, + }) +} + +// listTables returns the tables in a compartment. compartmentId is required, +// as it is in real OCI; the optional name parameter narrows the listing. +func (h *Handler) listTables(w http.ResponseWriter, r *http.Request) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + tables, err := h.extras.ListOCITables(r.Context(), compartmentID, r.URL.Query().Get("name")) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + items := make([]tableBody, 0, len(tables)) + for i := range tables { + items = append(items, toTableBody(&tables[i])) + } + + ocirest.WriteJSON(w, r, http.StatusOK, tableCollection{Items: paginate(w, r, items)}) +} + +func (h *Handler) getTable(w http.ResponseWriter, r *http.Request, id string) { + table, err := h.findTable(r, id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toTableBody(table)) +} + +// updateTable applies an ALTER TABLE statement, new limits and new tags. +func (h *Handler) updateTable(w http.ResponseWriter, r *http.Request, id string) { + if !h.requireWorkRequests(w, r) { + return + } + + var req updateTableRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + upd := nosqlprovider.TableUpdate{ + DDLStatement: req.DDLStatement, + IsAutoReclaimable: req.IsAutoReclaimable, + FreeformTags: req.FreeformTags, + } + + if req.TableLimits != nil { + limits := fromLimitsBody(req.TableLimits) + upd.Limits = &limits + } + + table, err := h.extras.UpdateOCITable(r.Context(), id, upd) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accepted(w, r, opUpdateTable, table.CompartmentID, workrequest.Resource{ + EntityType: entityTable, + ActionType: workrequest.ActionUpdated, + Identifier: table.ID, + }) +} + +func (h *Handler) deleteTable(w http.ResponseWriter, r *http.Request, id string) { + if !h.requireWorkRequests(w, r) { + return + } + + table, err := h.findTable(r, id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + if err := h.extras.DeleteOCITable(r.Context(), id); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accepted(w, r, opDeleteTable, table.CompartmentID, workrequest.Resource{ + EntityType: entityTable, + ActionType: workrequest.ActionDeleted, + Identifier: table.ID, + }) +} + +func (h *Handler) changeCompartment(w http.ResponseWriter, r *http.Request, id string) { + if !h.requireWorkRequests(w, r) { + return + } + + var req changeCompartmentRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.ToCompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "toCompartmentId is required") + return + } + + table, err := h.findTable(r, id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + if err := h.extras.ChangeOCITableCompartment(r.Context(), id, req.ToCompartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accepted(w, r, opChangeCompartment, req.ToCompartmentID, workrequest.Resource{ + EntityType: entityTable, + ActionType: workrequest.ActionUpdated, + Identifier: table.ID, + }) +} + +// findTable resolves a table by name or OCID and, when the caller names a +// compartment, checks the table is visible from it. OCI collapses a table in +// another compartment into the same 404 a missing one gets. +func (h *Handler) findTable(r *http.Request, id string) (*nosqlprovider.Table, error) { + table, err := h.extras.GetOCITable(r.Context(), id) + if err != nil { + return nil, err + } + + if compartmentID := ocirest.CompartmentID(r); compartmentID != "" && table.CompartmentID != compartmentID { + return nil, notFound(id) + } + + return table, nil +} diff --git a/server/oci/nosql/types.go b/server/oci/nosql/types.go new file mode 100644 index 000000000..e598db3a4 --- /dev/null +++ b/server/oci/nosql/types.go @@ -0,0 +1,196 @@ +package nosql + +import ( + nosqlprovider "github.com/stackshy/cloudemu/v2/providers/oci/nosql" +) + +// tableLimitsBody is OCI's TableLimits model. +type tableLimitsBody struct { + MaxReadUnits int `json:"maxReadUnits"` + MaxWriteUnits int `json:"maxWriteUnits"` + MaxStorageInGBs int `json:"maxStorageInGBs"` + CapacityMode string `json:"capacityMode,omitempty"` +} + +// createTableRequest is OCI's CreateTableDetails. +type createTableRequest struct { + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + DDLStatement string `json:"ddlStatement"` + TableLimits *tableLimitsBody `json:"tableLimits"` + IsAutoReclaimable bool `json:"isAutoReclaimable"` + FreeformTags map[string]string `json:"freeformTags"` +} + +// updateTableRequest is OCI's UpdateTableDetails. Every field is optional, so +// the pointers distinguish "absent" from "set to the zero value". +type updateTableRequest struct { + DDLStatement string `json:"ddlStatement"` + TableLimits *tableLimitsBody `json:"tableLimits"` + IsAutoReclaimable *bool `json:"isAutoReclaimable"` + FreeformTags map[string]string `json:"freeformTags"` +} + +// changeCompartmentRequest is OCI's ChangeTableCompartmentDetails. Real OCI +// also accepts fromCompartmentId; CloudEmu moves the table wherever it +// currently sits, so naming the source would be accepted and ignored. +type changeCompartmentRequest struct { + ToCompartmentID string `json:"toCompartmentId"` +} + +// columnBody is OCI's Column model. +type columnBody struct { + Name string `json:"name"` + Type string `json:"type"` + IsNullable bool `json:"isNullable"` + DefaultValue string `json:"defaultValue,omitempty"` +} + +// schemaBody is OCI's Schema model. ttl is in days, which is the only unit +// the model can carry. +type schemaBody struct { + Columns []columnBody `json:"columns"` + PrimaryKey []string `json:"primaryKey"` + ShardKey []string `json:"shardKey"` + TTL int `json:"ttl"` +} + +// tableBody is OCI's Table model, and the summary a listing carries. +type tableBody struct { + ID string `json:"id"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + TimeCreated string `json:"timeCreated"` + TimeUpdated string `json:"timeUpdated"` + TableLimits tableLimitsBody `json:"tableLimits"` + LifecycleState string `json:"lifecycleState"` + IsAutoReclaimable bool `json:"isAutoReclaimable"` + DDLStatement string `json:"ddlStatement"` + Schema schemaBody `json:"schema"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` +} + +// tableCollection is OCI's TableCollection. +type tableCollection struct { + Items []tableBody `json:"items"` +} + +// indexKeyBody is OCI's IndexKey model. jsonPath and jsonFieldType are absent: +// CloudEmu indexes declared columns only and refuses a JSON-path key by name. +type indexKeyBody struct { + ColumnName string `json:"columnName"` +} + +// indexBody is OCI's Index model, and the summary a listing carries. +type indexBody struct { + Name string `json:"name"` + Keys []indexKeyBody `json:"keys"` + LifecycleState string `json:"lifecycleState"` +} + +// indexCollection is OCI's IndexCollection. +type indexCollection struct { + Items []indexBody `json:"items"` +} + +// createIndexRequest is OCI's CreateIndexDetails. +type createIndexRequest struct { + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + Keys []indexKeyBody `json:"keys"` + IsIfNotExists bool `json:"isIfNotExists"` +} + +// updateRowRequest is OCI's UpdateRowDetails. +type updateRowRequest struct { + CompartmentID string `json:"compartmentId"` + Value map[string]any `json:"value"` + Option string `json:"option"` +} + +// rowBody is OCI's Row model. usage is absent: CloudEmu does not meter read +// and write unit consumption, so reporting zeros would read as real telemetry. +type rowBody struct { + Value map[string]any `json:"value"` + TimeOfExpiration string `json:"timeOfExpiration,omitempty"` +} + +// deleteRowResult is OCI's DeleteRowResult. +type deleteRowResult struct { + IsSuccess bool `json:"isSuccess"` +} + +// queryRequest is OCI's QueryDetails. +type queryRequest struct { + CompartmentID string `json:"compartmentId"` + Statement string `json:"statement"` + Limit int `json:"limit"` +} + +// queryResultCollection is OCI's QueryResultCollection. +type queryResultCollection struct { + Items []map[string]any `json:"items"` +} + +func toTableBody(t *nosqlprovider.Table) tableBody { + return tableBody{ + ID: t.ID, + Name: t.Name, + CompartmentID: t.CompartmentID, + TimeCreated: t.TimeCreated, + TimeUpdated: t.TimeUpdated, + TableLimits: toLimitsBody(t.Limits), + LifecycleState: t.LifecycleState, + IsAutoReclaimable: t.IsAutoReclaimable, + DDLStatement: t.DDLStatement, + Schema: toSchemaBody(&t.Schema), + FreeformTags: t.FreeformTags, + } +} + +func toLimitsBody(l nosqlprovider.TableLimits) tableLimitsBody { + return tableLimitsBody{ + MaxReadUnits: l.MaxReadUnits, + MaxWriteUnits: l.MaxWriteUnits, + MaxStorageInGBs: l.MaxStorageInGBs, + CapacityMode: l.CapacityMode, + } +} + +func fromLimitsBody(b *tableLimitsBody) nosqlprovider.TableLimits { + return nosqlprovider.TableLimits{ + MaxReadUnits: b.MaxReadUnits, + MaxWriteUnits: b.MaxWriteUnits, + MaxStorageInGBs: b.MaxStorageInGBs, + CapacityMode: b.CapacityMode, + } +} + +func toSchemaBody(s *nosqlprovider.Schema) schemaBody { + out := schemaBody{ + Columns: make([]columnBody, 0, len(s.Columns)), + PrimaryKey: s.PrimaryKey, + ShardKey: s.ShardKey, + TTL: s.TTL.Days, + } + + for _, c := range s.Columns { + out.Columns = append(out.Columns, columnBody{ + Name: c.Name, + Type: c.Type, + IsNullable: c.IsNullable, + DefaultValue: c.DefaultValue, + }) + } + + return out +} + +func toIndexBody(idx *nosqlprovider.Index) indexBody { + keys := make([]indexKeyBody, 0, len(idx.Keys)) + for _, k := range idx.Keys { + keys = append(keys, indexKeyBody{ColumnName: k.ColumnName}) + } + + return indexBody{Name: idx.Name, Keys: keys, LifecycleState: idx.LifecycleState} +} diff --git a/server/oci/oci.go b/server/oci/oci.go index a367d4810..3dbc65282 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/oci/identity" "github.com/stackshy/cloudemu/v2/server/oci/monitoring" + "github.com/stackshy/cloudemu/v2/server/oci/nosql" "github.com/stackshy/cloudemu/v2/server/oci/vcn" "github.com/stackshy/cloudemu/v2/server/oci/workrequest" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" @@ -96,6 +97,10 @@ func New(d Drivers) *server.Server { srv.Register(vcn.New(d.VCN, d.WorkRequests)) } + if d.NoSQL != nil { + srv.Register(nosql.New(d.NoSQL, d.WorkRequests)) + } + return srv } diff --git a/server/oci/oci_test.go b/server/oci/oci_test.go index 03e60474e..2816d676a 100644 --- a/server/oci/oci_test.go +++ b/server/oci/oci_test.go @@ -101,3 +101,34 @@ func TestDriversFromProviderBuildsServer(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } + +// TestNoSQLHandlerIsRegistered checks the NoSQL surface is reachable once the +// driver is wired, and that the shared work request poller still owns its own +// path under the same API version. +func TestNoSQLHandlerIsRegistered(t *testing.T) { + p := ociprovider.New(config.WithCompartmentID("ocid1.compartment.oc1..dev")) + + ts := httptest.NewServer(ociserver.New(ociserver.DriversFrom(p))) + defer ts.Close() + + resp, err := ts.Client().Get(ts.URL + "/20190828/tables?compartmentId=ocid1.compartment.oc1..dev") + require.NoError(t, err) + + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var coll struct { + Items []map[string]any `json:"items"` + } + + require.NoError(t, json.NewDecoder(resp.Body).Decode(&coll)) + assert.Empty(t, coll.Items) + + polled, err := ts.Client().Get(ts.URL + "/20190828/workRequests?compartmentId=ocid1.compartment.oc1..dev") + require.NoError(t, err) + + defer polled.Body.Close() + + assert.Equal(t, http.StatusOK, polled.StatusCode) +} From 5da591995e6fb07894729e328ccf90ac961bd371 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 23:36:41 +0530 Subject: [PATCH 2/3] fix(oci): scope NoSQL table names per compartment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCI scopes a NoSQL table name to its compartment, so the same name in two compartments is two tables. The table store is keyed by compartment and name, an OCID still resolves across the tenancy, and every OCI entry point takes the compartment alongside the tableNameOrId — which is what OCI's own request models carry it for. The portable driver has no compartment in its shape: it addresses the compartment new resources default to, then the sole compartment holding that name, and leaves a name held by two others unaddressable rather than picking between them. Also covers the query and scan operators, the typed column coercions and the handler error branches that had no test. --- providers/oci/nosql/index_extras.go | 32 +- providers/oci/nosql/indexes.go | 22 +- providers/oci/nosql/nosql.go | 144 ++++++-- providers/oci/nosql/nosql_test.go | 500 ++++++++++++++++++++++++++-- providers/oci/nosql/query.go | 20 +- providers/oci/nosql/race_test.go | 20 +- providers/oci/nosql/row_extras.go | 14 +- providers/oci/nosql/rows.go | 18 +- providers/oci/nosql/table_extras.go | 63 ++-- server/oci/nosql/handler.go | 30 +- server/oci/nosql/handler_test.go | 392 ++++++++++++++++++++++ server/oci/nosql/index.go | 25 +- server/oci/nosql/row.go | 36 +- server/oci/nosql/table.go | 42 ++- server/oci/nosql/types.go | 10 +- 15 files changed, 1137 insertions(+), 231 deletions(-) diff --git a/providers/oci/nosql/index_extras.go b/providers/oci/nosql/index_extras.go index 6c0f08ea7..be201e99c 100644 --- a/providers/oci/nosql/index_extras.go +++ b/providers/oci/nosql/index_extras.go @@ -8,11 +8,13 @@ import ( ) // CreateOCIIndex builds a secondary index from OCI's key list. -func (m *Mock) CreateOCIIndex(_ context.Context, nameOrID string, spec IndexSpec, ifNotExists bool) (*Index, error) { +func (m *Mock) CreateOCIIndex( + _ context.Context, compartmentID, nameOrID string, spec IndexSpec, ifNotExists bool, +) (*Index, error) { m.mu.Lock() defer m.mu.Unlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -34,11 +36,11 @@ func (m *Mock) CreateOCIIndex(_ context.Context, nameOrID string, spec IndexSpec } // GetOCIIndex returns one index on a table. -func (m *Mock) GetOCIIndex(_ context.Context, nameOrID, indexName string) (*Index, error) { +func (m *Mock) GetOCIIndex(_ context.Context, compartmentID, nameOrID, indexName string) (*Index, error) { m.mu.RLock() defer m.mu.RUnlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -53,11 +55,11 @@ func (m *Mock) GetOCIIndex(_ context.Context, nameOrID, indexName string) (*Inde // ListOCIIndexes returns a table's indexes ordered by name. A non-empty // indexName narrows the listing, as OCI's name query parameter does. -func (m *Mock) ListOCIIndexes(_ context.Context, nameOrID, indexName string) ([]Index, error) { +func (m *Mock) ListOCIIndexes(_ context.Context, compartmentID, nameOrID, indexName string) ([]Index, error) { m.mu.RLock() defer m.mu.RUnlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -79,11 +81,11 @@ func (m *Mock) ListOCIIndexes(_ context.Context, nameOrID, indexName string) ([] // DeleteOCIIndex drops an index. isIfExists makes dropping a missing index a // no-op, as OCI's query parameter of that name does. -func (m *Mock) DeleteOCIIndex(_ context.Context, nameOrID, indexName string, ifExists bool) error { +func (m *Mock) DeleteOCIIndex(_ context.Context, compartmentID, nameOrID, indexName string, ifExists bool) error { m.mu.Lock() defer m.mu.Unlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return err } @@ -99,20 +101,6 @@ func (m *Mock) DeleteOCIIndex(_ context.Context, nameOrID, indexName string, ifE return nil } -// OCITableScope returns the compartment a table lives in, which the handler -// stamps on the work requests it records. -func (m *Mock) OCITableScope(nameOrID string) string { - m.mu.RLock() - defer m.mu.RUnlock() - - t, err := m.resolve(nameOrID) - if err != nil { - return "" - } - - return t.Scope.Compartment -} - func cloneIndex(idx *Index) *Index { out := *idx out.Keys = append([]IndexKey(nil), idx.Keys...) diff --git a/providers/oci/nosql/indexes.go b/providers/oci/nosql/indexes.go index 81a03ad57..74ada7a85 100644 --- a/providers/oci/nosql/indexes.go +++ b/providers/oci/nosql/indexes.go @@ -13,7 +13,7 @@ func (m *Mock) CreateIndex(_ context.Context, table string, cfg driver.GSIConfig m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return nil, err } @@ -68,7 +68,7 @@ func (m *Mock) DeleteIndex(_ context.Context, table, indexName string) error { m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return err } @@ -97,7 +97,7 @@ func (m *Mock) DescribeIndex(_ context.Context, table, indexName string) (*drive m.mu.RLock() defer m.mu.RUnlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return nil, err } @@ -115,7 +115,7 @@ func (m *Mock) ListIndexes(_ context.Context, table string) ([]driver.IndexInfo, m.mu.RLock() defer m.mu.RUnlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (m *Mock) UpdateTTL(_ context.Context, table string, cfg driver.TTLConfig) m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return err } @@ -180,7 +180,7 @@ func (m *Mock) DescribeTTL(_ context.Context, table string) (*driver.TTLConfig, m.mu.RLock() defer m.mu.RUnlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return nil, err } @@ -197,7 +197,7 @@ func (m *Mock) UpdateStreamConfig(_ context.Context, table string, _ driver.Stre m.mu.RLock() defer m.mu.RUnlock() - if _, err := m.lookup(table); err != nil { + if _, err := m.lookup("", table); err != nil { return err } @@ -211,7 +211,7 @@ func (m *Mock) GetStreamRecords( m.mu.RLock() defer m.mu.RUnlock() - if _, err := m.lookup(table); err != nil { + if _, err := m.lookup("", table); err != nil { return nil, err } @@ -223,7 +223,7 @@ func (m *Mock) TagResource(_ context.Context, table string, tags map[string]stri m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return err } @@ -243,7 +243,7 @@ func (m *Mock) UntagResource(_ context.Context, table string, tagKeys []string) m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return err } @@ -262,7 +262,7 @@ func (m *Mock) ListTagsOfResource(_ context.Context, table string) (map[string]s m.mu.RLock() defer m.mu.RUnlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return nil, err } diff --git a/providers/oci/nosql/nosql.go b/providers/oci/nosql/nosql.go index 815bae854..fdeb05a99 100644 --- a/providers/oci/nosql/nosql.go +++ b/providers/oci/nosql/nosql.go @@ -13,6 +13,7 @@ import ( "fmt" "maps" "sort" + "strings" "sync" "time" @@ -172,9 +173,11 @@ type Mock struct { // resolve a name or OCID before touching the rows behind it. mu sync.RWMutex + // tables is keyed by tableKey: OCI scopes a table name to its compartment, + // so the same name in two compartments is two tables. tables *memstore.Store[*tableData] - // names maps a table OCID onto its name, so OCI callers can address a - // table either way. + // names maps a table OCID onto its store key, so OCI callers can address + // a table either way. names *memstore.Store[string] opts *config.Options monitoring mondriver.Monitoring @@ -210,24 +213,80 @@ func (m *Mock) now() string { return m.opts.Clock.Now().UTC().Format(timeFormat) } -// lookup returns a table by name. Callers must hold m.mu. -func (m *Mock) lookup(name string) (*tableData, error) { - t, ok := m.tables.Get(name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "table %q not found", name) +// keySeparator joins a compartment and a table name into one store key. It +// cannot appear in either, so no pair of them collides. +const keySeparator = "\x00" + +// tableKey scopes a table name to the compartment holding it. +func tableKey(compartmentID, name string) string { + return compartmentID + keySeparator + name +} + +// nameOf splits the table name back out of a store key. +func nameOf(key string) string { + _, name, _ := strings.Cut(key, keySeparator) + + return name +} + +// lookup returns a table by name from a compartment. An empty compartment is +// the portable driver, whose shape carries none: it reads the compartment new +// resources default to, then the sole compartment holding that name, so a +// table created over the OCI surface elsewhere is still reachable and a +// duplicated name is never picked between. Callers must hold m.mu. +func (m *Mock) lookup(compartmentID, name string) (*tableData, error) { + if compartmentID != "" { + if t, ok := m.tables.Get(tableKey(compartmentID, name)); ok { + return t, nil + } + + return nil, cerrors.Newf(cerrors.NotFound, "table %q not found in compartment %q", name, compartmentID) } - return t, nil + if t, ok := m.tables.Get(tableKey(m.opts.CompartmentID, name)); ok { + return t, nil + } + + if t, ok := m.soleTable(name); ok { + return t, nil + } + + return nil, cerrors.Newf(cerrors.NotFound, "table %q not found", name) +} + +// soleTable returns the table of that name when exactly one compartment holds +// it. Callers must hold m.mu. +func (m *Mock) soleTable(name string) (*tableData, bool) { + var found *tableData + + for _, key := range m.tables.Keys() { + if nameOf(key) != name { + continue + } + + if found != nil { + return nil, false + } + + found, _ = m.tables.Get(key) + } + + return found, found != nil } // resolve returns a table addressed by either its name or its OCID, which is -// what OCI's tableNameOrId path parameter accepts. Callers must hold m.mu. -func (m *Mock) resolve(nameOrID string) (*tableData, error) { - if name, ok := m.names.Get(nameOrID); ok { - return m.lookup(name) +// what OCI's tableNameOrId path parameter accepts. An OCID is unique across +// the tenancy and resolves on its own; a name needs the compartment scoping +// it, which is why OCI's request models carry compartmentId alongside it. +// Callers must hold m.mu. +func (m *Mock) resolve(compartmentID, nameOrID string) (*tableData, error) { + if key, ok := m.names.Get(nameOrID); ok { + if t, ok := m.tables.Get(key); ok { + return t, nil + } } - return m.lookup(nameOrID) + return m.lookup(compartmentID, nameOrID) } // itemKey is a row's identity: the shard key, then the sort key when the @@ -392,7 +451,7 @@ func (m *Mock) CreateTable(_ context.Context, cfg driver.TableConfig) error { schema := schemaFromConfig(&cfg) - t, err := m.newTable(cfg.Name, ddlFromSchema(cfg.Name, &schema), &schema, defaultLimits()) + t, err := m.newTable(m.opts.CompartmentID, cfg.Name, ddlFromSchema(cfg.Name, &schema), &schema, defaultLimits()) if err != nil { return err } @@ -412,10 +471,13 @@ func defaultLimits() TableLimits { return TableLimits{CapacityMode: CapacityOnDemand} } -// newTable records a new table. Callers must hold m.mu. -func (m *Mock) newTable(name, ddl string, schema *Schema, limits TableLimits) (*tableData, error) { - if m.tables.Has(name) { - return nil, cerrors.Newf(cerrors.AlreadyExists, "table %q already exists", name) +// newTable records a new table in a compartment. Callers must hold m.mu. +func (m *Mock) newTable( + compartmentID, name, ddl string, schema *Schema, limits TableLimits, +) (*tableData, error) { + key := tableKey(compartmentID, name) + if m.tables.Has(key) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "table %q already exists in compartment %q", name, compartmentID) } now := m.now() @@ -428,12 +490,12 @@ func (m *Mock) newTable(name, ddl string, schema *Schema, limits TableLimits) (* LifecycleState: StateActive, TimeCreated: now, TimeUpdated: now, - Scope: scope.Scope{Compartment: m.opts.CompartmentID}, + Scope: scope.Scope{Compartment: compartmentID}, items: memstore.New[map[string]any](), } - m.tables.Set(name, t) - m.names.Set(t.ID, name) + m.tables.Set(key, t) + m.names.Set(t.ID, key) return t, nil } @@ -443,28 +505,28 @@ func (m *Mock) DeleteTable(_ context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() - return m.dropTable(name) -} - -// dropTable removes a table by name. Callers must hold m.mu. -func (m *Mock) dropTable(name string) error { - t, err := m.lookup(name) + t, err := m.lookup("", name) if err != nil { return err } - m.tables.Delete(name) - m.names.Delete(t.ID) + m.dropTable(t) return nil } +// dropTable removes a table. Callers must hold m.mu. +func (m *Mock) dropTable(t *tableData) { + m.tables.Delete(tableKey(t.Scope.Compartment, t.Name)) + m.names.Delete(t.ID) +} + // DescribeTable returns the portable projection of a table. func (m *Mock) DescribeTable(_ context.Context, name string) (*driver.TableConfig, error) { m.mu.RLock() defer m.mu.RUnlock() - t, err := m.lookup(name) + t, err := m.lookup("", name) if err != nil { return nil, err } @@ -474,12 +536,30 @@ func (m *Mock) DescribeTable(_ context.Context, name string) (*driver.TableConfi return &cfg, nil } -// ListTables returns every table name, ordered. +// ListTables returns every table name the portable driver can address, which +// is every name lookup resolves without a compartment: a name held by two +// compartments neither of which is the default one is reachable over the OCI +// surface only, and is left out rather than listed twice. func (m *Mock) ListTables(_ context.Context) ([]string, error) { m.mu.RLock() defer m.mu.RUnlock() - names := m.tables.Keys() + seen := make(map[string]struct{}) + names := make([]string, 0, len(m.tables.Keys())) + + for _, key := range m.tables.Keys() { + name := nameOf(key) + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + + if _, err := m.lookup("", name); err == nil { + names = append(names, name) + } + } + sort.Strings(names) return names, nil diff --git a/providers/oci/nosql/nosql_test.go b/providers/oci/nosql/nosql_test.go index 2994ba5dc..ba0c8d62a 100644 --- a/providers/oci/nosql/nosql_test.go +++ b/providers/oci/nosql/nosql_test.go @@ -13,6 +13,7 @@ import ( cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/providers/oci/nosql" "github.com/stackshy/cloudemu/v2/services/database/driver" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" ) const ( @@ -173,7 +174,7 @@ func TestGetOCITableByNameOrOCID(t *testing.T) { table := createUsers(t, m) for _, addr := range []string{"users", table.ID} { - got, err := m.GetOCITable(context.Background(), addr) + got, err := m.GetOCITable(context.Background(), compartmentA, addr) require.NoError(t, err) assert.Equal(t, table.ID, got.ID) } @@ -182,7 +183,7 @@ func TestGetOCITableByNameOrOCID(t *testing.T) { func TestGetOCITableNotFound(t *testing.T) { m, _ := newMock(t) - _, err := m.GetOCITable(context.Background(), "missing") + _, err := m.GetOCITable(context.Background(), compartmentA, "missing") require.Error(t, err) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) @@ -233,7 +234,7 @@ func TestChangeOCITableCompartment(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - require.NoError(t, m.ChangeOCITableCompartment(context.Background(), "users", compartmentB)) + require.NoError(t, m.ChangeOCITableCompartment(context.Background(), compartmentA, "users", compartmentB)) inA, err := m.ListOCITables(context.Background(), compartmentA, "") require.NoError(t, err) @@ -251,7 +252,7 @@ func TestUpdateOCITable(t *testing.T) { reclaim := true - table, err := m.UpdateOCITable(context.Background(), "users", nosql.TableUpdate{ + table, err := m.UpdateOCITable(context.Background(), compartmentA, "users", nosql.TableUpdate{ DDLStatement: "ALTER TABLE users (ADD nickname STRING)", Limits: &nosql.TableLimits{CapacityMode: nosql.CapacityOnDemand}, IsAutoReclaimable: &reclaim, @@ -323,7 +324,7 @@ func TestUpdateOCITableErrors(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - _, err := m.UpdateOCITable(context.Background(), tc.table, tc.update) + _, err := m.UpdateOCITable(context.Background(), compartmentA, tc.table, tc.update) require.Error(t, err) assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) @@ -336,24 +337,24 @@ func TestDeleteOCITable(t *testing.T) { m, _ := newMock(t) table := createUsers(t, m) - require.NoError(t, m.DeleteOCITable(context.Background(), table.ID)) + require.NoError(t, m.DeleteOCITable(context.Background(), compartmentA, table.ID)) - _, err := m.GetOCITable(context.Background(), "users") + _, err := m.GetOCITable(context.Background(), compartmentA, "users") assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) assert.Equal(t, cerrors.NotFound, - cerrors.GetCode(m.DeleteOCITable(context.Background(), "users"))) + cerrors.GetCode(m.DeleteOCITable(context.Background(), compartmentA, "users"))) } func TestOCIRowRoundTrip(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - _, err := m.PutOCIRow(context.Background(), "users", + _, err := m.PutOCIRow(context.Background(), compartmentA, "users", map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"}, "") require.NoError(t, err) - row, err := m.GetOCIRow(context.Background(), "users", map[string]string{"id": "1", "email": "a@example.com"}) + row, err := m.GetOCIRow(context.Background(), compartmentA, "users", map[string]string{"id": "1", "email": "a@example.com"}) require.NoError(t, err) assert.Equal(t, "Ada", row.Value["name"]) @@ -361,12 +362,12 @@ func TestOCIRowRoundTrip(t *testing.T) { assert.Equal(t, int64(1), row.Value["id"]) assert.Empty(t, row.TimeOfExpiration) - deleted, err := m.DeleteOCIRow(context.Background(), "users", + deleted, err := m.DeleteOCIRow(context.Background(), compartmentA, "users", map[string]string{"id": "1", "email": "a@example.com"}) require.NoError(t, err) assert.True(t, deleted) - _, err = m.GetOCIRow(context.Background(), "users", map[string]string{"id": "1", "email": "a@example.com"}) + _, err = m.GetOCIRow(context.Background(), compartmentA, "users", map[string]string{"id": "1", "email": "a@example.com"}) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } @@ -374,7 +375,7 @@ func TestDeleteOCIRowReportsAbsence(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - deleted, err := m.DeleteOCIRow(context.Background(), "users", map[string]string{"id": "9", "email": "x@y.z"}) + deleted, err := m.DeleteOCIRow(context.Background(), compartmentA, "users", map[string]string{"id": "9", "email": "x@y.z"}) require.NoError(t, err) assert.False(t, deleted) } @@ -385,18 +386,18 @@ func TestPutOCIRowOptions(t *testing.T) { row := map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"} - _, err := m.PutOCIRow(context.Background(), "users", row, nosql.OptionIfPresent) + _, err := m.PutOCIRow(context.Background(), compartmentA, "users", row, nosql.OptionIfPresent) require.Error(t, err) assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) - _, err = m.PutOCIRow(context.Background(), "users", row, nosql.OptionIfAbsent) + _, err = m.PutOCIRow(context.Background(), compartmentA, "users", row, nosql.OptionIfAbsent) require.NoError(t, err) - _, err = m.PutOCIRow(context.Background(), "users", row, nosql.OptionIfAbsent) + _, err = m.PutOCIRow(context.Background(), compartmentA, "users", row, nosql.OptionIfAbsent) require.Error(t, err) assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) - _, err = m.PutOCIRow(context.Background(), "users", row, "MAYBE") + _, err = m.PutOCIRow(context.Background(), compartmentA, "users", row, "MAYBE") require.Error(t, err) assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) } @@ -439,7 +440,7 @@ func TestPutOCIRowValidation(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - _, err := m.PutOCIRow(context.Background(), "users", tc.value, "") + _, err := m.PutOCIRow(context.Background(), compartmentA, "users", tc.value, "") require.Error(t, err) assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) @@ -452,7 +453,7 @@ func TestGetOCIRowRejectsNonKeyColumn(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - _, err := m.GetOCIRow(context.Background(), "users", map[string]string{"name": "Ada"}) + _, err := m.GetOCIRow(context.Background(), compartmentA, "users", map[string]string{"name": "Ada"}) require.Error(t, err) assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) @@ -471,11 +472,11 @@ func TestTableTTLExpiresRows(t *testing.T) { }) require.NoError(t, err) - written, err := m.PutOCIRow(context.Background(), "sessions", map[string]any{"id": "s1"}, "") + written, err := m.PutOCIRow(context.Background(), compartmentA, "sessions", map[string]any{"id": "s1"}, "") require.NoError(t, err) assert.NotEmpty(t, written.TimeOfExpiration) - row, err := m.GetOCIRow(context.Background(), "sessions", map[string]string{"id": "s1"}) + row, err := m.GetOCIRow(context.Background(), compartmentA, "sessions", map[string]string{"id": "s1"}) require.NoError(t, err) assert.NotEmpty(t, row.TimeOfExpiration) // The expiry is metadata, not a column the caller sees in the value. @@ -483,7 +484,7 @@ func TestTableTTLExpiresRows(t *testing.T) { clock.Advance(3 * 24 * time.Hour) - _, err = m.GetOCIRow(context.Background(), "sessions", map[string]string{"id": "s1"}) + _, err = m.GetOCIRow(context.Background(), compartmentA, "sessions", map[string]string{"id": "s1"}) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } @@ -493,41 +494,41 @@ func TestOCIIndexes(t *testing.T) { spec := nosql.IndexSpec{Name: "byName", Columns: []string{"name"}} - idx, err := m.CreateOCIIndex(context.Background(), "users", spec, false) + idx, err := m.CreateOCIIndex(context.Background(), compartmentA, "users", spec, false) require.NoError(t, err) assert.Equal(t, nosql.StateActive, idx.LifecycleState) assert.Equal(t, []nosql.IndexKey{{ColumnName: "name"}}, idx.Keys) - _, err = m.CreateOCIIndex(context.Background(), "users", spec, false) + _, err = m.CreateOCIIndex(context.Background(), compartmentA, "users", spec, false) require.Error(t, err) assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) - again, err := m.CreateOCIIndex(context.Background(), "users", spec, true) + again, err := m.CreateOCIIndex(context.Background(), compartmentA, "users", spec, true) require.NoError(t, err) assert.Equal(t, "byName", again.Name) - got, err := m.GetOCIIndex(context.Background(), "users", "byName") + got, err := m.GetOCIIndex(context.Background(), compartmentA, "users", "byName") require.NoError(t, err) assert.Equal(t, "byName", got.Name) - list, err := m.ListOCIIndexes(context.Background(), "users", "") + list, err := m.ListOCIIndexes(context.Background(), compartmentA, "users", "") require.NoError(t, err) require.Len(t, list, 1) - require.NoError(t, m.DeleteOCIIndex(context.Background(), "users", "byName", false)) + require.NoError(t, m.DeleteOCIIndex(context.Background(), compartmentA, "users", "byName", false)) - err = m.DeleteOCIIndex(context.Background(), "users", "byName", false) + err = m.DeleteOCIIndex(context.Background(), compartmentA, "users", "byName", false) require.Error(t, err) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) - require.NoError(t, m.DeleteOCIIndex(context.Background(), "users", "byName", true)) + require.NoError(t, m.DeleteOCIIndex(context.Background(), compartmentA, "users", "byName", true)) } func TestCreateOCIIndexRejectsUndeclaredColumn(t *testing.T) { m, _ := newMock(t) createUsers(t, m) - _, err := m.CreateOCIIndex(context.Background(), "users", + _, err := m.CreateOCIIndex(context.Background(), compartmentA, "users", nosql.IndexSpec{Name: "bad", Columns: []string{"nope"}}, false) require.Error(t, err) @@ -540,12 +541,12 @@ func TestQueryOCISelectAndDelete(t *testing.T) { createUsers(t, m) for _, email := range []string{"a@x.com", "b@x.com"} { - _, err := m.PutOCIRow(context.Background(), "users", + _, err := m.PutOCIRow(context.Background(), compartmentA, "users", map[string]any{"id": float64(1), "email": email, "name": "Ada"}, "") require.NoError(t, err) } - _, err := m.PutOCIRow(context.Background(), "users", + _, err := m.PutOCIRow(context.Background(), compartmentA, "users", map[string]any{"id": float64(2), "email": "c@x.com", "name": "Grace"}, "") require.NoError(t, err) @@ -668,7 +669,7 @@ func TestPortableTableCRUD(t *testing.T) { assert.Equal(t, []string{"portable"}, names) // A table created portably still reports the DDL OCI callers expect. - table, err := m.GetOCITable(ctx, "portable") + table, err := m.GetOCITable(ctx, compartmentA, "portable") require.NoError(t, err) assert.Equal(t, "CREATE TABLE portable (pk STRING, sk STRING, PRIMARY KEY (SHARD(pk), sk))", table.DDLStatement) assert.Equal(t, nosql.CapacityOnDemand, table.Limits.CapacityMode) @@ -922,10 +923,435 @@ func TestPortableTags(t *testing.T) { assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } -func TestOCITableScope(t *testing.T) { +// TestTableNamesScopePerCompartment is the per-compartment naming contract: +// real OCI scopes a NoSQL table name to its compartment, so the same name in +// two compartments is two tables, each addressing its own rows. +func TestTableNamesScopePerCompartment(t *testing.T) { m, _ := newMock(t) + ctx := context.Background() + + inA := createUsers(t, m) + + inB, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: usersDDL, + Limits: provisioned(), + }) + require.NoError(t, err) + assert.NotEqual(t, inA.ID, inB.ID) + + got, err := m.GetOCITable(ctx, compartmentB, "users") + require.NoError(t, err) + assert.Equal(t, inB.ID, got.ID) + + _, err = m.PutOCIRow(ctx, compartmentB, "users", + map[string]any{"id": float64(1), "email": "b@example.com", "name": "Bea"}, "") + require.NoError(t, err) + + _, err = m.GetOCIRow(ctx, compartmentA, "users", map[string]string{"id": "1", "email": "b@example.com"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + listA, err := m.ListOCITables(ctx, compartmentA, "") + require.NoError(t, err) + require.Len(t, listA, 1) + assert.Equal(t, inA.ID, listA[0].ID) + + require.NoError(t, m.DeleteOCITable(ctx, compartmentB, "users")) + + _, err = m.GetOCITable(ctx, compartmentA, "users") + require.NoError(t, err) +} + +// TestGetOCITableRejectsOtherCompartment pins that naming a table from a +// compartment that does not hold it is a 404, whether it is addressed by name +// or by the OCID the handler checks the compartment of. +func TestGetOCITableRejectsOtherCompartment(t *testing.T) { + m, _ := newMock(t) + table := createUsers(t, m) + + _, err := m.GetOCITable(context.Background(), compartmentB, "users") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + // An OCID is unique across the tenancy, so it still resolves; the handler + // is what collapses the compartment mismatch into a 404. + got, err := m.GetOCITable(context.Background(), compartmentB, table.ID) + require.NoError(t, err) + assert.Equal(t, compartmentA, got.CompartmentID) +} + +// TestChangeOCITableCompartmentNameTaken refuses a move that would collide +// with a table of the same name already in the destination. +func TestChangeOCITableCompartmentNameTaken(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + createUsers(t, m) + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: usersDDL, + Limits: provisioned(), + }) + require.NoError(t, err) + + err = m.ChangeOCITableCompartment(ctx, compartmentA, "users", compartmentB) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + require.Error(t, m.ChangeOCITableCompartment(ctx, compartmentA, "users", "")) +} + +// TestPortableAddressesTablesAcrossCompartments pins how the portable driver, +// whose shape carries no compartment, addresses a table once names are scoped: +// the compartment new resources default to, then the sole compartment holding +// that name. +func TestPortableAddressesTablesAcrossCompartments(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: "CREATE TABLE audits (id STRING, PRIMARY KEY (id))", + Limits: provisioned(), + }) + require.NoError(t, err) + + // Held by one compartment only, so the portable driver reaches it. + cfg, err := m.DescribeTable(ctx, "audits") + require.NoError(t, err) + assert.Equal(t, "id", cfg.PartitionKey) + + names, err := m.ListTables(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"audits"}, names) + + // Duplicated across compartments, one of them the default: that one wins. + createUsers(t, m) + + _, err = m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: usersDDL, + Limits: provisioned(), + }) + require.NoError(t, err) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"id": int64(1), "email": "a@example.com"})) + + row, err := m.GetOCIRow(ctx, compartmentA, "users", map[string]string{"id": "1", "email": "a@example.com"}) + require.NoError(t, err) + assert.Equal(t, int64(1), row.Value["id"]) + + _, err = m.GetOCIRow(ctx, compartmentB, "users", map[string]string{"id": "1", "email": "a@example.com"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +// TestPortableSkipsAmbiguousNames leaves a name held by two non-default +// compartments unaddressable rather than picking one of them. +func TestPortableSkipsAmbiguousNames(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + for _, c := range []string{compartmentB, "ocid1.compartment.oc1..cccc"} { + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: c, + DDLStatement: "CREATE TABLE audits (id STRING, PRIMARY KEY (id))", + Limits: provisioned(), + }) + require.NoError(t, err) + } + + _, err := m.DescribeTable(ctx, "audits") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + names, err := m.ListTables(ctx) + require.NoError(t, err) + assert.Empty(t, names) +} + +// TestQueryOCIScopesTableByCompartment runs the same statement in two +// compartments and gets each compartment's own rows back. +func TestQueryOCIScopesTableByCompartment(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + createUsers(t, m) - assert.Equal(t, compartmentA, m.OCITableScope("users")) - assert.Empty(t, m.OCITableScope("missing")) + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: usersDDL, + Limits: provisioned(), + }) + require.NoError(t, err) + + _, err = m.PutOCIRow(ctx, compartmentA, "users", + map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"}, "") + require.NoError(t, err) + + rows, err := m.QueryOCI(ctx, compartmentA, "SELECT * FROM users", 0) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "Ada", rows[0]["name"]) + + rows, err = m.QueryOCI(ctx, compartmentB, "SELECT * FROM users", 0) + require.NoError(t, err) + assert.Empty(t, rows) +} + +// TestPortableQuerySortOperators runs every sort-key operator the portable +// driver declares, on both a lexical and a numeric sort key: the comparison +// orders numerically when both sides parse as numbers and lexically otherwise. +func TestPortableQuerySortOperators(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.BatchPutItems(ctx, "t", []map[string]any{ + {"pk": "a", "sk": "2"}, + {"pk": "a", "sk": "10"}, + {"pk": "a", "sk": "30"}, + {"pk": "a", "sk": "beta"}, + })) + + tests := []struct { + name string + op string + val any + end any + expect []string + }{ + {name: "less than orders numerically", op: nosql.OpLessThan, val: "30", expect: []string{"10", "2"}}, + {name: "greater than", op: nosql.OpGreaterThan, val: "2", expect: []string{"10", "30", "beta"}}, + {name: "less or equal", op: nosql.OpLessEqual, val: "10", expect: []string{"10", "2"}}, + // "beta" parses as no number, so it is compared lexically and sorts above every digit. + {name: "greater or equal", op: nosql.OpGreaterEqual, val: "30", expect: []string{"30", "beta"}}, + {name: "between spans both ends", op: nosql.OpBetween, val: "2", end: "10", expect: []string{"10", "2"}}, + {name: "begins with is lexical", op: nosql.OpBeginsWith, val: "be", expect: []string{"beta"}}, + {name: "contains is lexical", op: nosql.OpContains, val: "et", expect: []string{"beta"}}, + {name: "not equal", op: nosql.OpNotEqual, val: "beta", expect: []string{"10", "2", "30"}}, + {name: "an unknown operator matches nothing", op: "LIKE", val: "beta"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res, err := m.Query(ctx, driver.QueryInput{ + Table: "t", + KeyCondition: driver.KeyCondition{ + PartitionKey: "pk", PartitionVal: "a", + SortOp: tc.op, SortVal: tc.val, SortValEnd: tc.end, + }, + }) + require.NoError(t, err) + + got := make([]string, 0, len(res.Items)) + for _, item := range res.Items { + got = append(got, item["sk"].(string)) + } + + assert.ElementsMatch(t, tc.expect, got) + }) + } +} + +// TestPortableScanFilterOperators runs the same comparisons through Scan's +// filters, which is the other caller of the shared operator. +func TestPortableScanFilterOperators(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + require.NoError(t, m.BatchPutItems(ctx, "t", []map[string]any{ + {"pk": "a", "n": "5", "s": "alpha"}, + {"pk": "b", "n": "40", "s": "beta"}, + })) + + tests := []struct { + name string + filter driver.ScanFilter + expect int + }{ + {name: "numeric less than", filter: driver.ScanFilter{Field: "n", Op: nosql.OpLessThan, Value: "40"}, expect: 1}, + {name: "numeric greater equal", filter: driver.ScanFilter{Field: "n", Op: nosql.OpGreaterEqual, Value: "5"}, expect: 2}, + {name: "lexical greater than", filter: driver.ScanFilter{Field: "s", Op: nosql.OpGreaterThan, Value: "alpha"}, expect: 1}, + {name: "lexical less equal", filter: driver.ScanFilter{Field: "s", Op: nosql.OpLessEqual, Value: "alpha"}, expect: 1}, + {name: "contains", filter: driver.ScanFilter{Field: "s", Op: nosql.OpContains, Value: "eta"}, expect: 1}, + {name: "begins with", filter: driver.ScanFilter{Field: "s", Op: nosql.OpBeginsWith, Value: "al"}, expect: 1}, + {name: "not equal", filter: driver.ScanFilter{Field: "s", Op: nosql.OpNotEqual, Value: "beta"}, expect: 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res, err := m.Scan(ctx, driver.ScanInput{Table: "t", Filters: []driver.ScanFilter{tc.filter}}) + require.NoError(t, err) + assert.Equal(t, tc.expect, res.Count) + }) + } +} + +// TestPortableQueryOnIndex orders and filters on an index's own key columns +// rather than the table's. +func TestPortableQueryOnIndex(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: "CREATE TABLE people (pk STRING, sk STRING, city STRING, age STRING, " + + "PRIMARY KEY (SHARD(pk), sk))", + Limits: provisioned(), + }) + require.NoError(t, err) + + _, err = m.CreateOCIIndex(ctx, compartmentA, "people", + nosql.IndexSpec{Name: "byCity", Columns: []string{"city", "age"}}, false) + require.NoError(t, err) + + require.NoError(t, m.BatchPutItems(ctx, "people", []map[string]any{ + {"pk": "1", "sk": "a", "city": "pune", "age": "30"}, + {"pk": "2", "sk": "b", "city": "pune", "age": "40"}, + {"pk": "3", "sk": "c", "city": "goa", "age": "50"}, + })) + + res, err := m.Query(ctx, driver.QueryInput{ + Table: "people", + IndexName: "byCity", + KeyCondition: driver.KeyCondition{ + PartitionKey: "city", PartitionVal: "pune", SortOp: nosql.OpGreaterThan, SortVal: "30", + }, + }) + require.NoError(t, err) + require.Equal(t, 1, res.Count) + assert.Equal(t, "2", res.Items[0]["pk"]) +} + +// TestTypedColumnCoercion covers every column type a row value is fitted to, +// through the wire's string key form and the JSON body's decoded form. +func TestTypedColumnCoercion(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + ddl := "CREATE TABLE typed (id LONG, ratio DOUBLE, score FLOAT, amount NUMBER, ok BOOLEAN, " + + "blob BINARY, at TIMESTAMP, doc JSON, PRIMARY KEY (id))" + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentA, DDLStatement: ddl, Limits: provisioned(), + }) + require.NoError(t, err) + + value := map[string]any{ + "id": float64(7), "ratio": 1.5, "score": 2.5, "amount": 3.5, "ok": true, + "blob": "YWJj", "at": "2026-08-21T12:00:00Z", "doc": map[string]any{"k": "v"}, + } + + _, err = m.PutOCIRow(ctx, compartmentA, "typed", value, "") + require.NoError(t, err) + + // The LONG key round-trips through parseTyped's string form. + row, err := m.GetOCIRow(ctx, compartmentA, "typed", map[string]string{"id": "7"}) + require.NoError(t, err) + assert.Equal(t, int64(7), row.Value["id"]) + assert.InEpsilon(t, 1.5, row.Value["ratio"], 1e-9) + assert.Equal(t, true, row.Value["ok"]) + assert.Equal(t, "YWJj", row.Value["blob"]) + assert.Equal(t, map[string]any{"k": "v"}, row.Value["doc"]) +} + +func TestTypedColumnCoercionErrors(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + ddl := "CREATE TABLE typed (id LONG, ratio DOUBLE, ok BOOLEAN, at TIMESTAMP, PRIMARY KEY (id))" + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentA, DDLStatement: ddl, Limits: provisioned(), + }) + require.NoError(t, err) + + tests := []struct { + name string + value map[string]any + }{ + {name: "a LONG takes no fraction", value: map[string]any{"id": 1.5}}, + {name: "a LONG takes no string", value: map[string]any{"id": "seven"}}, + {name: "a DOUBLE takes no string", value: map[string]any{"id": float64(1), "ratio": "1.5"}}, + {name: "a BOOLEAN takes no string", value: map[string]any{"id": float64(1), "ok": "yes"}}, + {name: "a TIMESTAMP takes no number", value: map[string]any{"id": float64(1), "at": float64(1)}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := m.PutOCIRow(ctx, compartmentA, "typed", tc.value, "") + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + }) + } + + // A key value that does not parse as its column's type is refused too. + for _, key := range []map[string]string{{"id": "seven"}} { + _, err := m.GetOCIRow(ctx, compartmentA, "typed", key) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + } +} + +// TestTypedColumnDefaults fills an absent column from its DDL default, parsed +// into the column's type rather than left as the declared text. +func TestTypedColumnDefaults(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + ddl := "CREATE TABLE defs (id LONG, hits LONG DEFAULT 3, ratio DOUBLE DEFAULT 1.5, " + + "ok BOOLEAN DEFAULT true, tag STRING, PRIMARY KEY (id))" + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentA, DDLStatement: ddl, Limits: provisioned(), + }) + require.NoError(t, err) + + _, err = m.PutOCIRow(ctx, compartmentA, "defs", map[string]any{"id": float64(1)}, "") + require.NoError(t, err) + + row, err := m.GetOCIRow(ctx, compartmentA, "defs", map[string]string{"id": "1"}) + require.NoError(t, err) + assert.Equal(t, int64(3), row.Value["hits"]) + assert.InEpsilon(t, 1.5, row.Value["ratio"], 1e-9) + assert.Equal(t, true, row.Value["ok"]) + assert.Nil(t, row.Value["tag"]) +} + +// captureMonitoring records the metric data the mock publishes. The embedded +// interface stays nil: emitMetric calls PutMetricData and nothing else. +type captureMonitoring struct { + mondriver.Monitoring + + data []mondriver.MetricDatum +} + +func (c *captureMonitoring) PutMetricData(_ context.Context, data []mondriver.MetricDatum) error { + c.data = append(c.data, data...) + + return nil +} + +// TestSetMonitoringPublishesUnits points the mock at a monitoring service and +// checks read and write unit consumption reaches it, dimensioned by table. +func TestSetMonitoringPublishesUnits(t *testing.T) { + m, _ := newMock(t) + mon := &captureMonitoring{} + m.SetMonitoring(mon) + + ctx := context.Background() + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "t", PartitionKey: "pk"})) + require.NoError(t, m.PutItem(ctx, "t", map[string]any{"pk": "a"})) + + _, err := m.GetItem(ctx, "t", map[string]any{"pk": "a"}) + require.NoError(t, err) + + names := make([]string, 0, len(mon.data)) + for _, d := range mon.data { + assert.Equal(t, "oci_nosql", d.Namespace) + assert.Equal(t, "t", d.Dimensions["tableName"]) + + names = append(names, d.MetricName) + } + + assert.Subset(t, names, []string{"ReadUnits", "WriteUnits"}) } diff --git a/providers/oci/nosql/query.go b/providers/oci/nosql/query.go index 474e89c39..fdded80b0 100644 --- a/providers/oci/nosql/query.go +++ b/providers/oci/nosql/query.go @@ -8,7 +8,6 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" - "github.com/stackshy/cloudemu/v2/services/scope" ) // Statement kinds the query endpoint runs. OCI's NoSQL REST API has no @@ -61,7 +60,7 @@ func (m *Mock) runSelect(compartmentID, stmt string, limit int) ([]map[string]an m.mu.RLock() defer m.mu.RUnlock() - t, err := m.scopedTable(table, compartmentID) + t, err := m.lookup(compartmentID, table) if err != nil { return nil, err } @@ -92,7 +91,7 @@ func (m *Mock) runDelete(compartmentID, stmt string) ([]map[string]any, error) { m.mu.Lock() defer m.mu.Unlock() - t, err := m.scopedTable(table, compartmentID) + t, err := m.lookup(compartmentID, table) if err != nil { return nil, err } @@ -109,21 +108,6 @@ func (m *Mock) runDelete(compartmentID, stmt string) ([]map[string]any, error) { return []map[string]any{{deletedRowsField: len(matched)}}, nil } -// scopedTable resolves a table and checks it is visible from the caller's -// compartment. Callers must hold m.mu. -func (m *Mock) scopedTable(name, compartmentID string) (*tableData, error) { - t, err := m.resolve(name) - if err != nil { - return nil, err - } - - if !t.Scope.Matches(scope.Scope{Compartment: compartmentID}) { - return nil, cerrors.Newf(cerrors.NotFound, "table %q not found", name) - } - - return t, nil -} - // matchRows returns the unexpired rows satisfying every condition, in a // deterministic order. Callers must hold m.mu. func (m *Mock) matchRows(t *tableData, conds []condition) ([]map[string]any, error) { diff --git a/providers/oci/nosql/race_test.go b/providers/oci/nosql/race_test.go index fdf91a658..8e205bf89 100644 --- a/providers/oci/nosql/race_test.go +++ b/providers/oci/nosql/race_test.go @@ -36,7 +36,7 @@ func TestConcurrentRowsAndTableReads(t *testing.T) { go func() { defer wg.Done() - _, err := m.PutOCIRow(ctx, "users", map[string]any{ + _, err := m.PutOCIRow(ctx, compartmentA, "users", map[string]any{ "id": float64(i), "email": fmt.Sprintf("u%d@x.com", i), "name": "n", }, "") assert.NoError(t, err) @@ -46,7 +46,7 @@ func TestConcurrentRowsAndTableReads(t *testing.T) { defer wg.Done() // Missing rows are expected while the writers are still running. - _, _ = m.GetOCIRow(ctx, "users", map[string]string{ + _, _ = m.GetOCIRow(ctx, compartmentA, "users", map[string]string{ "id": fmt.Sprint(i), "email": fmt.Sprintf("u%d@x.com", i), }) }() @@ -54,7 +54,7 @@ func TestConcurrentRowsAndTableReads(t *testing.T) { go func() { defer wg.Done() - _, err := m.GetOCITable(ctx, "users") + _, err := m.GetOCITable(ctx, compartmentA, "users") assert.NoError(t, err) }() @@ -99,7 +99,7 @@ func TestConcurrentTableMutationAndProjection(t *testing.T) { // Exactly one goroutine wins each column name; the rest see // AlreadyExists, never a corrupted column list. - _, err := m.UpdateOCITable(ctx, "users", nosql.TableUpdate{ + _, err := m.UpdateOCITable(ctx, compartmentA, "users", nosql.TableUpdate{ DDLStatement: fmt.Sprintf("ALTER TABLE users (ADD c%d STRING)", i), }) assert.NoError(t, err) @@ -108,7 +108,7 @@ func TestConcurrentTableMutationAndProjection(t *testing.T) { go func() { defer wg.Done() - _, err := m.CreateOCIIndex(ctx, "users", + _, err := m.CreateOCIIndex(ctx, compartmentA, "users", nosql.IndexSpec{Name: fmt.Sprintf("i%d", i), Columns: []string{"name"}}, true) assert.NoError(t, err) }() @@ -116,7 +116,7 @@ func TestConcurrentTableMutationAndProjection(t *testing.T) { go func() { defer wg.Done() - table, err := m.GetOCITable(ctx, "users") + table, err := m.GetOCITable(ctx, compartmentA, "users") if assert.NoError(t, err) { assert.NotEmpty(t, table.Schema.PrimaryKey) } @@ -125,18 +125,18 @@ func TestConcurrentTableMutationAndProjection(t *testing.T) { go func() { defer wg.Done() - _, err := m.ListOCIIndexes(ctx, "users", "") + _, err := m.ListOCIIndexes(ctx, compartmentA, "users", "") assert.NoError(t, err) }() } wg.Wait() - table, err := m.GetOCITable(ctx, "users") + table, err := m.GetOCITable(ctx, compartmentA, "users") require.NoError(t, err) assert.Len(t, table.Schema.Columns, 3+raceGoroutines) - indexes, err := m.ListOCIIndexes(ctx, "users", "") + indexes, err := m.ListOCIIndexes(ctx, compartmentA, "users", "") require.NoError(t, err) assert.Len(t, indexes, raceGoroutines) } @@ -158,7 +158,7 @@ func TestConcurrentQueryDelete(t *testing.T) { go func() { defer wg.Done() - _, err := m.PutOCIRow(ctx, "users", map[string]any{ + _, err := m.PutOCIRow(ctx, compartmentA, "users", map[string]any{ "id": float64(i), "email": "x@y.z", "name": "n", }, "") assert.NoError(t, err) diff --git a/providers/oci/nosql/row_extras.go b/providers/oci/nosql/row_extras.go index c46459717..6c85314e9 100644 --- a/providers/oci/nosql/row_extras.go +++ b/providers/oci/nosql/row_extras.go @@ -16,11 +16,11 @@ const ( // GetOCIRow returns one row by primary key. The key arrives as the wire's // column:value strings and is coerced to the column types the schema declares. -func (m *Mock) GetOCIRow(_ context.Context, nameOrID string, key map[string]string) (*Row, error) { +func (m *Mock) GetOCIRow(_ context.Context, compartmentID, nameOrID string, key map[string]string) (*Row, error) { m.mu.RLock() defer m.mu.RUnlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -40,11 +40,13 @@ func (m *Mock) GetOCIRow(_ context.Context, nameOrID string, key map[string]stri // PutOCIRow writes a row. The option, when set, makes the write conditional on // the row's absence or presence, as OCI's IF_ABSENT and IF_PRESENT do. -func (m *Mock) PutOCIRow(_ context.Context, nameOrID string, value map[string]any, option string) (*Row, error) { +func (m *Mock) PutOCIRow( + _ context.Context, compartmentID, nameOrID string, value map[string]any, option string, +) (*Row, error) { m.mu.Lock() defer m.mu.Unlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -76,11 +78,11 @@ func (m *Mock) PutOCIRow(_ context.Context, nameOrID string, value map[string]an // DeleteOCIRow removes one row, reporting whether it was there. OCI's // DeleteRow answers 200 either way. -func (m *Mock) DeleteOCIRow(_ context.Context, nameOrID string, key map[string]string) (bool, error) { +func (m *Mock) DeleteOCIRow(_ context.Context, compartmentID, nameOrID string, key map[string]string) (bool, error) { m.mu.Lock() defer m.mu.Unlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return false, err } diff --git a/providers/oci/nosql/rows.go b/providers/oci/nosql/rows.go index 541dd5f6d..99e78d93a 100644 --- a/providers/oci/nosql/rows.go +++ b/providers/oci/nosql/rows.go @@ -29,7 +29,7 @@ const ( func (m *Mock) PutItem(_ context.Context, table string, item map[string]any) error { m.mu.Lock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { m.mu.Unlock() return err @@ -74,7 +74,7 @@ func (m *Mock) expiryOf(t *tableData) int64 { func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map[string]any, error) { m.mu.RLock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { m.mu.RUnlock() return nil, err @@ -103,7 +103,7 @@ func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[ m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(input.Table) + t, err := m.lookup("", input.Table) if err != nil { return nil, err } @@ -138,7 +138,7 @@ func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[ func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) error { m.mu.Lock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { m.mu.Unlock() return err @@ -159,7 +159,7 @@ func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) e func (m *Mock) Query(_ context.Context, input driver.QueryInput) (*driver.QueryResult, error) { m.mu.RLock() - t, err := m.lookup(input.Table) + t, err := m.lookup("", input.Table) if err != nil { m.mu.RUnlock() return nil, err @@ -206,7 +206,7 @@ func (m *Mock) Query(_ context.Context, input driver.QueryInput) (*driver.QueryR func (m *Mock) Scan(_ context.Context, input driver.ScanInput) (*driver.QueryResult, error) { m.mu.RLock() - t, err := m.lookup(input.Table) + t, err := m.lookup("", input.Table) if err != nil { m.mu.RUnlock() return nil, err @@ -361,7 +361,7 @@ func (m *Mock) BatchPutItems(_ context.Context, table string, items []map[string m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return err } @@ -379,7 +379,7 @@ func (m *Mock) BatchGetItems(_ context.Context, table string, keys []map[string] m.mu.RLock() defer m.mu.RUnlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return nil, err } @@ -407,7 +407,7 @@ func (m *Mock) TransactWriteItems( m.mu.Lock() defer m.mu.Unlock() - t, err := m.lookup(table) + t, err := m.lookup("", table) if err != nil { return err } diff --git a/providers/oci/nosql/table_extras.go b/providers/oci/nosql/table_extras.go index 25ed644f0..3809e2fd7 100644 --- a/providers/oci/nosql/table_extras.go +++ b/providers/oci/nosql/table_extras.go @@ -35,20 +35,20 @@ func (m *Mock) CreateOCITable(_ context.Context, spec TableSpec) (*Table, error) return nil, err } - if existing, ok := m.tables.Get(d.Table); ok { + if existing, ok := m.tables.Get(tableKey(spec.CompartmentID, d.Table)); ok { if d.IfNotExists { return ptr(toTable(existing)), nil } - return nil, cerrors.Newf(cerrors.AlreadyExists, "table %q already exists", d.Table) + return nil, cerrors.Newf(cerrors.AlreadyExists, + "table %q already exists in compartment %q", d.Table, spec.CompartmentID) } - t, err := m.newTable(d.Table, normaliseStatement(spec.DDLStatement), &d.Schema, limits) + t, err := m.newTable(spec.CompartmentID, d.Table, normaliseStatement(spec.DDLStatement), &d.Schema, limits) if err != nil { return nil, err } - t.Scope = scope.Scope{Compartment: spec.CompartmentID} t.IsAutoReclaimable = spec.IsAutoReclaimable t.Tags = maps.Clone(spec.FreeformTags) @@ -87,11 +87,11 @@ func normaliseLimits(l TableLimits) (TableLimits, error) { } // GetOCITable returns a table by name or OCID. -func (m *Mock) GetOCITable(_ context.Context, nameOrID string) (*Table, error) { +func (m *Mock) GetOCITable(_ context.Context, compartmentID, nameOrID string) (*Table, error) { m.mu.RLock() defer m.mu.RUnlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -107,13 +107,11 @@ func (m *Mock) ListOCITables(_ context.Context, compartmentID, name string) ([]T defer m.mu.RUnlock() filter := scope.Scope{Compartment: compartmentID} - names := m.tables.Keys() - sort.Strings(names) + keys := m.tables.Keys() + out := make([]Table, 0, len(keys)) - out := make([]Table, 0, len(names)) - - for _, n := range names { - t, ok := m.tables.Get(n) + for _, k := range keys { + t, ok := m.tables.Get(k) if !ok || !t.Scope.Matches(filter) { continue } @@ -125,16 +123,18 @@ func (m *Mock) ListOCITables(_ context.Context, compartmentID, name string) ([]T out = append(out, toTable(t)) } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil } // UpdateOCITable applies an ALTER TABLE statement, new limits, tags and the // auto-reclaim flag. Every field is optional, as UpdateTable's are. -func (m *Mock) UpdateOCITable(_ context.Context, nameOrID string, upd TableUpdate) (*Table, error) { +func (m *Mock) UpdateOCITable(_ context.Context, compartmentID, nameOrID string, upd TableUpdate) (*Table, error) { m.mu.Lock() defer m.mu.Unlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return nil, err } @@ -243,34 +243,51 @@ func isKeyColumn(t *tableData, name string) bool { } // DeleteOCITable drops a table addressed by name or OCID. -func (m *Mock) DeleteOCITable(_ context.Context, nameOrID string) error { +func (m *Mock) DeleteOCITable(_ context.Context, compartmentID, nameOrID string) error { m.mu.Lock() defer m.mu.Unlock() - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return err } - return m.dropTable(t.Name) + m.dropTable(t) + + return nil } -// ChangeOCITableCompartment moves a table into another compartment. -func (m *Mock) ChangeOCITableCompartment(_ context.Context, nameOrID, compartmentID string) error { +// ChangeOCITableCompartment moves a table into another compartment, which +// re-keys it: the destination refuses the move when it already holds a table +// of that name. +func (m *Mock) ChangeOCITableCompartment(_ context.Context, compartmentID, nameOrID, toCompartmentID string) error { m.mu.Lock() defer m.mu.Unlock() - if compartmentID == "" { - return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + if toCompartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "toCompartmentId is required") } - t, err := m.resolve(nameOrID) + t, err := m.resolve(compartmentID, nameOrID) if err != nil { return err } - t.Scope = scope.Scope{Compartment: compartmentID} + if toCompartmentID == t.Scope.Compartment { + return nil + } + + key := tableKey(toCompartmentID, t.Name) + if m.tables.Has(key) { + return cerrors.Newf(cerrors.AlreadyExists, + "table %q already exists in compartment %q", t.Name, toCompartmentID) + } + + m.tables.Delete(tableKey(t.Scope.Compartment, t.Name)) + t.Scope = scope.Scope{Compartment: toCompartmentID} t.TimeUpdated = m.now() + m.tables.Set(key, t) + m.names.Set(t.ID, key) return nil } diff --git a/server/oci/nosql/handler.go b/server/oci/nosql/handler.go index 3bb03bc89..72e5cbdba 100644 --- a/server/oci/nosql/handler.go +++ b/server/oci/nosql/handler.go @@ -88,33 +88,35 @@ const maxPathSegments = 5 // Extras is the OCI-only surface the portable database driver cannot express: // tables are created from a DDL statement rather than a key list, carry an // OCID, a compartment and capacity limits, and rows are addressed by typed -// primary key columns. *providers/oci/nosql.Mock satisfies it; any driver +// primary key columns. Every table-addressing method takes the compartment +// alongside the name-or-OCID, as OCI's own request models do: a table name is +// unique within a compartment only. *providers/oci/nosql.Mock satisfies it; any driver // that does not is served 501 for every path this handler claims. type Extras interface { CreateOCITable(ctx context.Context, spec nosqlprovider.TableSpec) (*nosqlprovider.Table, error) - GetOCITable(ctx context.Context, nameOrID string) (*nosqlprovider.Table, error) + GetOCITable(ctx context.Context, compartmentID, nameOrID string) (*nosqlprovider.Table, error) ListOCITables(ctx context.Context, compartmentID, name string) ([]nosqlprovider.Table, error) UpdateOCITable( - ctx context.Context, nameOrID string, upd nosqlprovider.TableUpdate, + ctx context.Context, compartmentID, nameOrID string, upd nosqlprovider.TableUpdate, ) (*nosqlprovider.Table, error) - DeleteOCITable(ctx context.Context, nameOrID string) error - ChangeOCITableCompartment(ctx context.Context, nameOrID, compartmentID string) error + DeleteOCITable(ctx context.Context, compartmentID, nameOrID string) error + ChangeOCITableCompartment(ctx context.Context, compartmentID, nameOrID, toCompartmentID string) error CreateOCIIndex( - ctx context.Context, nameOrID string, spec nosqlprovider.IndexSpec, ifNotExists bool, + ctx context.Context, compartmentID, nameOrID string, spec nosqlprovider.IndexSpec, ifNotExists bool, ) (*nosqlprovider.Index, error) - GetOCIIndex(ctx context.Context, nameOrID, indexName string) (*nosqlprovider.Index, error) - ListOCIIndexes(ctx context.Context, nameOrID, indexName string) ([]nosqlprovider.Index, error) - DeleteOCIIndex(ctx context.Context, nameOrID, indexName string, ifExists bool) error + GetOCIIndex(ctx context.Context, compartmentID, nameOrID, indexName string) (*nosqlprovider.Index, error) + ListOCIIndexes(ctx context.Context, compartmentID, nameOrID, indexName string) ([]nosqlprovider.Index, error) + DeleteOCIIndex(ctx context.Context, compartmentID, nameOrID, indexName string, ifExists bool) error - GetOCIRow(ctx context.Context, nameOrID string, key map[string]string) (*nosqlprovider.Row, error) + GetOCIRow( + ctx context.Context, compartmentID, nameOrID string, key map[string]string, + ) (*nosqlprovider.Row, error) PutOCIRow( - ctx context.Context, nameOrID string, value map[string]any, option string, + ctx context.Context, compartmentID, nameOrID string, value map[string]any, option string, ) (*nosqlprovider.Row, error) - DeleteOCIRow(ctx context.Context, nameOrID string, key map[string]string) (bool, error) + DeleteOCIRow(ctx context.Context, compartmentID, nameOrID string, key map[string]string) (bool, error) QueryOCI(ctx context.Context, compartmentID, statement string, limit int) ([]map[string]any, error) - - OCITableScope(nameOrID string) string } // Handler serves OCI NoSQL Database against a database driver. diff --git a/server/oci/nosql/handler_test.go b/server/oci/nosql/handler_test.go index 3e4f9e865..97c3f6c66 100644 --- a/server/oci/nosql/handler_test.go +++ b/server/oci/nosql/handler_test.go @@ -737,3 +737,395 @@ func TestWorkRequestsUnconfiguredIsNotImplemented(t *testing.T) { type plainDriver struct { dbdriver.Database } + +// TestSameTableNameInTwoCompartments is the per-compartment naming contract on +// the wire: an SDK that reuses a table name across compartments creates two +// tables, and each compartmentId addresses its own. +func TestSameTableNameInTwoCompartments(t *testing.T) { + h, _ := newHandler(t) + + idA := createTable(t, h) + + rec := do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "compartmentId": compartmentB, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{ + "maxReadUnits": 50, "maxWriteUnits": 50, "maxStorageInGBs": 1, "capacityMode": "PROVISIONED", + }, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + + var inB struct { + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + } + + got := do(t, h, http.MethodGet, "/20190828/tables/users?compartmentId="+compartmentB, nil) + require.Equal(t, http.StatusOK, got.Code) + require.NoError(t, json.Unmarshal(got.Body.Bytes(), &inB)) + + assert.NotEqual(t, idA, inB.ID) + assert.Equal(t, compartmentB, inB.CompartmentID) + + // A row written to one is invisible to the other. + rec = do(t, h, http.MethodPut, "/20190828/tables/users/rows", map[string]any{ + "compartmentId": compartmentB, + "value": map[string]any{"id": 1, "email": "b@example.com", "name": "Bea"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = do(t, h, http.MethodGet, + "/20190828/tables/users/rows?compartmentId="+compartmentA+"&key=id:1&key=email:b@example.com", nil) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // Each listing sees one table. + for _, c := range []string{compartmentA, compartmentB} { + rec = do(t, h, http.MethodGet, "/20190828/tables?compartmentId="+c, nil) + require.Equal(t, http.StatusOK, rec.Code) + + var list struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Items, 1) + assert.Equal(t, "users", list.Items[0].Name) + } + + // Deleting one leaves the other. + rec = do(t, h, http.MethodDelete, "/20190828/tables/users?compartmentId="+compartmentB, nil) + require.Equal(t, http.StatusAccepted, rec.Code) + + rec = do(t, h, http.MethodGet, "/20190828/tables/users?compartmentId="+compartmentA, nil) + assert.Equal(t, http.StatusOK, rec.Code) + + rec = do(t, h, http.MethodGet, "/20190828/tables/users?compartmentId="+compartmentB, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestIndexesScopePerCompartment pins that an index is created on the table in +// the compartment the request names, not on a same-named one elsewhere. +func TestIndexesScopePerCompartment(t *testing.T) { + h, _ := newHandler(t) + + createTable(t, h) + + rec := do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "compartmentId": compartmentB, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{ + "maxReadUnits": 50, "maxWriteUnits": 50, "maxStorageInGBs": 1, "capacityMode": "PROVISIONED", + }, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + + rec = do(t, h, http.MethodPost, "/20190828/tables/users/indexes", map[string]any{ + "compartmentId": compartmentB, + "name": "byName", + "keys": []map[string]any{{"columnName": "name"}}, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + + rec = do(t, h, http.MethodGet, "/20190828/tables/users/indexes?compartmentId="+compartmentB, nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "byName") + + rec = do(t, h, http.MethodGet, "/20190828/tables/users/indexes?compartmentId="+compartmentA, nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "byName") +} + +// TestChangeCompartmentScopesFromCompartment moves the table the request's +// fromCompartmentId names, leaving a same-named table elsewhere alone. +func TestChangeCompartmentScopesFromCompartment(t *testing.T) { + h, _ := newHandler(t) + + idA := createTable(t, h) + toC := "ocid1.compartment.oc1..cccc" + + rec := do(t, h, http.MethodPost, "/20190828/tables/users/actions/changeCompartment", map[string]any{ + "fromCompartmentId": compartmentA, + "toCompartmentId": toC, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + + got := do(t, h, http.MethodGet, "/20190828/tables/users?compartmentId="+toC, nil) + require.Equal(t, http.StatusOK, got.Code) + assert.Contains(t, got.Body.String(), idA) + + got = do(t, h, http.MethodGet, "/20190828/tables/users?compartmentId="+compartmentA, nil) + assert.Equal(t, http.StatusNotFound, got.Code) +} + +// TestAsyncPathsNeedWorkRequests: every mutation real OCI runs asynchronously +// reports the missing work request store rather than half-serving the request. +func TestAsyncPathsNeedWorkRequests(t *testing.T) { + h := ocinosql.New(nosqlprovider.New(config.NewOptions()), nil) + + tests := []struct { + name string + method string + target string + }{ + {name: "update table", method: http.MethodPut, target: "/20190828/tables/users"}, + {name: "delete table", method: http.MethodDelete, target: "/20190828/tables/users"}, + {name: "create index", method: http.MethodPost, target: "/20190828/tables/users/indexes"}, + {name: "delete index", method: http.MethodDelete, target: "/20190828/tables/users/indexes/byName"}, + { + name: "change compartment", method: http.MethodPost, + target: "/20190828/tables/users/actions/changeCompartment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.target, map[string]any{}) + assert.Equal(t, http.StatusNotImplemented, rec.Code) + }) + } +} + +// TestMalformedBodies refuses a body that is not the JSON the route models. +func TestMalformedBodies(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + tests := []struct { + name string + method string + target string + }{ + {name: "create table", method: http.MethodPost, target: "/20190828/tables"}, + {name: "update table", method: http.MethodPut, target: "/20190828/tables/users"}, + {name: "create index", method: http.MethodPost, target: "/20190828/tables/users/indexes"}, + {name: "update row", method: http.MethodPut, target: "/20190828/tables/users/rows"}, + {name: "query", method: http.MethodPost, target: "/20190828/query"}, + { + name: "change compartment", method: http.MethodPost, + target: "/20190828/tables/users/actions/changeCompartment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.target, strings.NewReader("{"))) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +// TestTableMutationErrors drives the failure branches of the mutating table +// routes: a statement of the wrong kind, and a table that is not there. +func TestTableMutationErrors(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + rec := do(t, h, http.MethodPut, "/20190828/tables/users", map[string]any{ + "compartmentId": compartmentA, + "ddlStatement": usersDDL, + }) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + rec = do(t, h, http.MethodPut, "/20190828/tables/missing", map[string]any{ + "compartmentId": compartmentA, + "ddlStatement": "ALTER TABLE missing (ADD nickname STRING)", + }) + assert.Equal(t, http.StatusNotFound, rec.Code) + + rec = do(t, h, http.MethodDelete, "/20190828/tables/missing?compartmentId="+compartmentA, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) + + rec = do(t, h, http.MethodPost, "/20190828/tables/missing/actions/changeCompartment", map[string]any{ + "fromCompartmentId": compartmentA, + "toCompartmentId": compartmentB, + }) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // A move into a compartment already holding that name is a conflict. + rec = do(t, h, http.MethodPost, "/20190828/tables", map[string]any{ + "compartmentId": compartmentB, + "ddlStatement": usersDDL, + "tableLimits": map[string]any{"capacityMode": "ON_DEMAND"}, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + + rec = do(t, h, http.MethodPost, "/20190828/tables/users/actions/changeCompartment", map[string]any{ + "fromCompartmentId": compartmentA, + "toCompartmentId": compartmentB, + }) + assert.Equal(t, http.StatusConflict, rec.Code) +} + +// TestIndexAndRowErrors drives the failure branches of the index and row +// routes: a table that is not there, and an index that is not. +func TestIndexAndRowErrors(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + tests := []struct { + name string + method string + target string + body any + expect int + }{ + { + name: "create index on a missing table", method: http.MethodPost, + target: "/20190828/tables/missing/indexes", + body: map[string]any{"compartmentId": compartmentA, "name": "byName"}, + expect: http.StatusNotFound, + }, + { + name: "create index with no key column", method: http.MethodPost, + target: "/20190828/tables/users/indexes", + body: map[string]any{"compartmentId": compartmentA, "name": "byNothing"}, + expect: http.StatusBadRequest, + }, + { + name: "list indexes of a missing table", method: http.MethodGet, + target: "/20190828/tables/missing/indexes?compartmentId=" + compartmentA, + expect: http.StatusNotFound, + }, + { + name: "get a missing index", method: http.MethodGet, + target: "/20190828/tables/users/indexes/byNothing?compartmentId=" + compartmentA, + expect: http.StatusNotFound, + }, + { + name: "get an index of a missing table", method: http.MethodGet, + target: "/20190828/tables/missing/indexes/byName?compartmentId=" + compartmentA, + expect: http.StatusNotFound, + }, + { + name: "delete a missing index", method: http.MethodDelete, + target: "/20190828/tables/users/indexes/byNothing?compartmentId=" + compartmentA, + expect: http.StatusNotFound, + }, + { + name: "delete an index of a missing table", method: http.MethodDelete, + target: "/20190828/tables/missing/indexes/byName?compartmentId=" + compartmentA, + expect: http.StatusNotFound, + }, + { + name: "get a row of a missing table", method: http.MethodGet, + target: "/20190828/tables/missing/rows?compartmentId=" + compartmentA + "&key=id:1", + expect: http.StatusNotFound, + }, + { + name: "delete a row of a missing table", method: http.MethodDelete, + target: "/20190828/tables/missing/rows?compartmentId=" + compartmentA + "&key=id:1", + expect: http.StatusNotFound, + }, + { + name: "put a row to a missing table", method: http.MethodPut, + target: "/20190828/tables/missing/rows", + body: map[string]any{ + "compartmentId": compartmentA, + "value": map[string]any{"id": 1, "email": "a@example.com"}, + }, + expect: http.StatusNotFound, + }, + { + name: "delete a row named by no key", method: http.MethodDelete, + target: "/20190828/tables/users/rows?compartmentId=" + compartmentA, + expect: http.StatusBadRequest, + }, + { + name: "delete a row whose key is not a pair", method: http.MethodDelete, + target: "/20190828/tables/users/rows?compartmentId=" + compartmentA + "&key=id", + expect: http.StatusBadRequest, + }, + { + name: "delete a row whose key is not a primary key column", method: http.MethodDelete, + target: "/20190828/tables/users/rows?compartmentId=" + compartmentA + "&key=name:Ada", + expect: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.target, tc.body) + assert.Equal(t, tc.expect, rec.Code) + }) + } +} + +// TestIndexIfNotExistsAndIfExists takes the idempotent branches OCI's +// isIfNotExists and isIfExists parameters select. +func TestIndexIfNotExistsAndIfExists(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + body := map[string]any{ + "compartmentId": compartmentA, + "name": "byName", + "keys": []map[string]any{{"columnName": "name"}}, + "isIfNotExists": true, + } + + for range 2 { + rec := do(t, h, http.MethodPost, "/20190828/tables/users/indexes", body) + require.Equal(t, http.StatusAccepted, rec.Code) + } + + rec := do(t, h, http.MethodDelete, + "/20190828/tables/users/indexes/byName?compartmentId="+compartmentA+"&isIfExists=true", nil) + require.Equal(t, http.StatusAccepted, rec.Code) + + rec = do(t, h, http.MethodDelete, + "/20190828/tables/users/indexes/byName?compartmentId="+compartmentA+"&isIfExists=true", nil) + assert.Equal(t, http.StatusAccepted, rec.Code) +} + +// TestListPaginates walks a listing a page at a time with OCI's limit and the +// opaque page cursor the previous response stamped. +func TestListPaginates(t *testing.T) { + h, _ := newHandler(t) + createTable(t, h) + + for _, column := range []string{"name", "email"} { + rec := do(t, h, http.MethodPost, "/20190828/tables/users/indexes", map[string]any{ + "compartmentId": compartmentA, + "name": "by" + column, + "keys": []map[string]any{{"columnName": column}}, + }) + require.Equal(t, http.StatusAccepted, rec.Code) + } + + seen := make([]string, 0, 2) + page := "" + + for range 2 { + target := "/20190828/tables/users/indexes?compartmentId=" + compartmentA + "&limit=1" + if page != "" { + target += "&page=" + page + } + + rec := do(t, h, http.MethodGet, target, nil) + require.Equal(t, http.StatusOK, rec.Code) + + var list struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Items, 1) + + seen = append(seen, list.Items[0].Name) + page = rec.Header().Get(ocirest.HeaderNextPage) + } + + assert.ElementsMatch(t, []string{"byname", "byemail"}, seen) + + // A cursor past the end is an empty page, not an error. + rec := do(t, h, http.MethodGet, + "/20190828/tables/users/indexes?compartmentId="+compartmentA+"&page=99", nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"items":[]`) +} diff --git a/server/oci/nosql/index.go b/server/oci/nosql/index.go index c3d3f0713..6c0981288 100644 --- a/server/oci/nosql/index.go +++ b/server/oci/nosql/index.go @@ -50,13 +50,15 @@ func (h *Handler) createIndex(w http.ResponseWriter, r *http.Request, tableID st spec.Columns = append(spec.Columns, k.ColumnName) } - table, err := h.findTable(r, tableID) + compartmentID := compartmentOf(r, req.CompartmentID) + + table, err := h.findTable(r, compartmentID, tableID) if err != nil { ocirest.WriteDriverError(w, r, err) return } - if _, err := h.extras.CreateOCIIndex(r.Context(), tableID, spec, req.IsIfNotExists); err != nil { + if _, err := h.extras.CreateOCIIndex(r.Context(), compartmentID, tableID, spec, req.IsIfNotExists); err != nil { ocirest.WriteDriverError(w, r, err) return } @@ -71,16 +73,17 @@ func (h *Handler) createIndex(w http.ResponseWriter, r *http.Request, tableID st // listIndexes returns a table's indexes. Real OCI marks compartmentId // optional here; CloudEmu requires it so every list is compartment-scoped. func (h *Handler) listIndexes(w http.ResponseWriter, r *http.Request, tableID string) { - if _, given := ocirest.RequireCompartmentID(w, r); !given { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { return } - if _, err := h.findTable(r, tableID); err != nil { + if _, err := h.findTable(r, compartmentID, tableID); err != nil { ocirest.WriteDriverError(w, r, err) return } - indexes, err := h.extras.ListOCIIndexes(r.Context(), tableID, r.URL.Query().Get("name")) + indexes, err := h.extras.ListOCIIndexes(r.Context(), compartmentID, tableID, r.URL.Query().Get("name")) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -95,12 +98,14 @@ func (h *Handler) listIndexes(w http.ResponseWriter, r *http.Request, tableID st } func (h *Handler) getIndex(w http.ResponseWriter, r *http.Request, tableID, name string) { - if _, err := h.findTable(r, tableID); err != nil { + compartmentID := ocirest.CompartmentID(r) + + if _, err := h.findTable(r, compartmentID, tableID); err != nil { ocirest.WriteDriverError(w, r, err) return } - idx, err := h.extras.GetOCIIndex(r.Context(), tableID, name) + idx, err := h.extras.GetOCIIndex(r.Context(), compartmentID, tableID, name) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -114,7 +119,9 @@ func (h *Handler) deleteIndex(w http.ResponseWriter, r *http.Request, tableID, n return } - table, err := h.findTable(r, tableID) + compartmentID := ocirest.CompartmentID(r) + + table, err := h.findTable(r, compartmentID, tableID) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -122,7 +129,7 @@ func (h *Handler) deleteIndex(w http.ResponseWriter, r *http.Request, tableID, n ifExists := r.URL.Query().Get("isIfExists") == "true" - if err := h.extras.DeleteOCIIndex(r.Context(), tableID, name, ifExists); err != nil { + if err := h.extras.DeleteOCIIndex(r.Context(), compartmentID, tableID, name, ifExists); err != nil { ocirest.WriteDriverError(w, r, err) return } diff --git a/server/oci/nosql/row.go b/server/oci/nosql/row.go index bf6dea6db..5e58c3a51 100644 --- a/server/oci/nosql/row.go +++ b/server/oci/nosql/row.go @@ -29,12 +29,14 @@ func (h *Handler) getRow(w http.ResponseWriter, r *http.Request, tableID string) return } - if _, err := h.findTable(r, tableID); err != nil { + compartmentID := ocirest.CompartmentID(r) + + if _, err := h.findTable(r, compartmentID, tableID); err != nil { ocirest.WriteDriverError(w, r, err) return } - row, err := h.extras.GetOCIRow(r.Context(), tableID, key) + row, err := h.extras.GetOCIRow(r.Context(), compartmentID, tableID, key) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -58,11 +60,14 @@ func (h *Handler) putRow(w http.ResponseWriter, r *http.Request, tableID string) return } - if !h.rowCompartmentMatches(w, r, tableID, req.CompartmentID) { + compartmentID := compartmentOf(r, req.CompartmentID) + + if _, err := h.findTable(r, compartmentID, tableID); err != nil { + ocirest.WriteDriverError(w, r, err) return } - if _, err := h.extras.PutOCIRow(r.Context(), tableID, req.Value, req.Option); err != nil { + if _, err := h.extras.PutOCIRow(r.Context(), compartmentID, tableID, req.Value, req.Option); err != nil { ocirest.WriteDriverError(w, r, err) return } @@ -76,12 +81,14 @@ func (h *Handler) deleteRow(w http.ResponseWriter, r *http.Request, tableID stri return } - if _, err := h.findTable(r, tableID); err != nil { + compartmentID := ocirest.CompartmentID(r) + + if _, err := h.findTable(r, compartmentID, tableID); err != nil { ocirest.WriteDriverError(w, r, err) return } - deleted, err := h.extras.DeleteOCIRow(r.Context(), tableID, key) + deleted, err := h.extras.DeleteOCIRow(r.Context(), compartmentID, tableID, key) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -90,23 +97,6 @@ func (h *Handler) deleteRow(w http.ResponseWriter, r *http.Request, tableID stri ocirest.WriteJSON(w, r, http.StatusOK, deleteRowResult{IsSuccess: deleted}) } -// rowCompartmentMatches checks a body-supplied compartmentId against the -// table's, the way the query-parameter form is checked on the read paths. -func (h *Handler) rowCompartmentMatches(w http.ResponseWriter, r *http.Request, tableID, compartmentID string) bool { - table, err := h.extras.GetOCITable(r.Context(), tableID) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return false - } - - if compartmentID != "" && table.CompartmentID != compartmentID { - ocirest.WriteDriverError(w, r, notFound(tableID)) - return false - } - - return true -} - // decodeKey reads OCI's repeated key parameter, each entry a "column:value" // pair. A pair without a colon is refused rather than read as a bare column. func decodeKey(w http.ResponseWriter, r *http.Request) (map[string]string, bool) { diff --git a/server/oci/nosql/table.go b/server/oci/nosql/table.go index 246ed5056..584b8f4cf 100644 --- a/server/oci/nosql/table.go +++ b/server/oci/nosql/table.go @@ -48,7 +48,7 @@ func (h *Handler) createTable(w http.ResponseWriter, r *http.Request) { // CreateTableDetails carries a name alongside the DDL; the two must agree, // since the DDL is what actually names the table. if req.Name != "" && req.Name != table.Name { - _ = h.extras.DeleteOCITable(r.Context(), table.Name) + _ = h.extras.DeleteOCITable(r.Context(), table.CompartmentID, table.Name) ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "name "+req.Name+" does not match the table named by ddlStatement, "+table.Name) @@ -86,7 +86,7 @@ func (h *Handler) listTables(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getTable(w http.ResponseWriter, r *http.Request, id string) { - table, err := h.findTable(r, id) + table, err := h.findTable(r, ocirest.CompartmentID(r), id) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -118,7 +118,7 @@ func (h *Handler) updateTable(w http.ResponseWriter, r *http.Request, id string) upd.Limits = &limits } - table, err := h.extras.UpdateOCITable(r.Context(), id, upd) + table, err := h.extras.UpdateOCITable(r.Context(), compartmentOf(r, req.CompartmentID), id, upd) if err != nil { ocirest.WriteDriverError(w, r, err) return @@ -136,13 +136,15 @@ func (h *Handler) deleteTable(w http.ResponseWriter, r *http.Request, id string) return } - table, err := h.findTable(r, id) + compartmentID := ocirest.CompartmentID(r) + + table, err := h.findTable(r, compartmentID, id) if err != nil { ocirest.WriteDriverError(w, r, err) return } - if err := h.extras.DeleteOCITable(r.Context(), id); err != nil { + if err := h.extras.DeleteOCITable(r.Context(), compartmentID, id); err != nil { ocirest.WriteDriverError(w, r, err) return } @@ -170,13 +172,15 @@ func (h *Handler) changeCompartment(w http.ResponseWriter, r *http.Request, id s return } - table, err := h.findTable(r, id) + compartmentID := compartmentOf(r, req.FromCompartmentID) + + table, err := h.findTable(r, compartmentID, id) if err != nil { ocirest.WriteDriverError(w, r, err) return } - if err := h.extras.ChangeOCITableCompartment(r.Context(), id, req.ToCompartmentID); err != nil { + if err := h.extras.ChangeOCITableCompartment(r.Context(), compartmentID, id, req.ToCompartmentID); err != nil { ocirest.WriteDriverError(w, r, err) return } @@ -188,18 +192,30 @@ func (h *Handler) changeCompartment(w http.ResponseWriter, r *http.Request, id s }) } -// findTable resolves a table by name or OCID and, when the caller names a -// compartment, checks the table is visible from it. OCI collapses a table in -// another compartment into the same 404 a missing one gets. -func (h *Handler) findTable(r *http.Request, id string) (*nosqlprovider.Table, error) { - table, err := h.extras.GetOCITable(r.Context(), id) +// findTable resolves a table by name or OCID within the caller's compartment, +// then checks the table is visible from it — the scoped lookup already is, but +// an OCID resolves across the tenancy. OCI collapses a table in another +// compartment into the same 404 a missing one gets. +func (h *Handler) findTable(r *http.Request, compartmentID, id string) (*nosqlprovider.Table, error) { + table, err := h.extras.GetOCITable(r.Context(), compartmentID, id) if err != nil { return nil, err } - if compartmentID := ocirest.CompartmentID(r); compartmentID != "" && table.CompartmentID != compartmentID { + if compartmentID != "" && table.CompartmentID != compartmentID { return nil, notFound(id) } return table, nil } + +// compartmentOf takes the compartment a request body names, falling back to +// the compartmentId query parameter. OCI puts it in the body on the routes +// that have one and in the query string on the rest. +func compartmentOf(r *http.Request, body string) string { + if body != "" { + return body + } + + return ocirest.CompartmentID(r) +} diff --git a/server/oci/nosql/types.go b/server/oci/nosql/types.go index e598db3a4..6d88904c6 100644 --- a/server/oci/nosql/types.go +++ b/server/oci/nosql/types.go @@ -25,17 +25,19 @@ type createTableRequest struct { // updateTableRequest is OCI's UpdateTableDetails. Every field is optional, so // the pointers distinguish "absent" from "set to the zero value". type updateTableRequest struct { + CompartmentID string `json:"compartmentId"` DDLStatement string `json:"ddlStatement"` TableLimits *tableLimitsBody `json:"tableLimits"` IsAutoReclaimable *bool `json:"isAutoReclaimable"` FreeformTags map[string]string `json:"freeformTags"` } -// changeCompartmentRequest is OCI's ChangeTableCompartmentDetails. Real OCI -// also accepts fromCompartmentId; CloudEmu moves the table wherever it -// currently sits, so naming the source would be accepted and ignored. +// changeCompartmentRequest is OCI's ChangeTableCompartmentDetails. +// fromCompartmentId scopes the table name in the path, which is unique within +// a compartment only; it is redundant when the path carries an OCID. type changeCompartmentRequest struct { - ToCompartmentID string `json:"toCompartmentId"` + FromCompartmentID string `json:"fromCompartmentId"` + ToCompartmentID string `json:"toCompartmentId"` } // columnBody is OCI's Column model. From 9522865328ff5e7d075e8ad099f1dcc304f40950 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 23:58:22 +0530 Subject: [PATCH 3/3] feat(oci): persist NoSQL state and fix numeric query literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds providers/oci/nosql/snapshot.go, mirroring the VCN mock's shared storeDump table so Snapshot and Restore cannot drift. Each table nests its own row store, which JSON neither writes nor rebuilds, so tableData carries custom JSON methods: the rows travel as a plain map and the store is remade from them, alongside the unexported attribute TTL. Rows are re-fitted to their declared column types on the way back, since JSON decodes every number to a float64 and an INTEGER key would otherwise come back as one. The mock mints no lazy default or counter, so the two stores are the whole of its state. A query condition on a numeric column is now compared by value rather than by the text form %v spells it in, so a DOUBLE holding 5 matches the literal 5.0. Other columns stay a text comparison, and the portable driver's equality — which has no column types to consult — documents the limit. Also adds the four hugeParam directives the interface-fixed portable signatures need, and re-runs the coverage generator. --- docs/coverage/README.md | 2 +- docs/coverage/oci/nosql.md | 12 -- providers/oci/nosql/indexes.go | 2 + providers/oci/nosql/nosql.go | 2 + providers/oci/nosql/nosql_test.go | 47 +++++++ providers/oci/nosql/query.go | 35 ++++- providers/oci/nosql/rows.go | 9 +- providers/oci/nosql/snapshot.go | 186 +++++++++++++++++++++++++++ providers/oci/nosql/snapshot_test.go | 168 ++++++++++++++++++++++++ 9 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 providers/oci/nosql/snapshot.go create mode 100644 providers/oci/nosql/snapshot_test.go diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 67243417b..1b750b945 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -59,7 +59,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `cosmospostgresql` | — | [CosmosPostgreSQL](./azure/cosmospostgresql.md) | — | — | 34 | | `costexplorer` | [CostExplorer](./aws/costexplorer.md) | — | — | — | 4 | | `costmanagement` | — | [Costmanagement](./azure/costmanagement.md) | — | — | 1 | -| `database` | [DynamoDB](./aws/dynamodb.md) | [CosmosDB](./azure/cosmosdb.md) | [Firestore](./gcp/firestore.md) | — | 24 | +| `database` | [DynamoDB](./aws/dynamodb.md) | [CosmosDB](./azure/cosmosdb.md) | [Firestore](./gcp/firestore.md) | [NoSQL](./oci/nosql.md) | 24 | | `databricks` | — | [Databricks](./azure/databricks.md) | — | — | 46 | | `datacatalog` | — | — | [DataCatalog](./gcp/datacatalog.md) | — | 21 | | `datafactory` | — | [DataFactory](./azure/datafactory.md) | — | — | 6 | diff --git a/docs/coverage/oci/nosql.md b/docs/coverage/oci/nosql.md index 19ec44cdf..bf801639d 100644 --- a/docs/coverage/oci/nosql.md +++ b/docs/coverage/oci/nosql.md @@ -32,18 +32,6 @@ OCI's `database` service · portable interface `driver.Database` · [OCI index]( | `UpdateStreamConfig` | Streams / Change Feed | | `UpdateTTL` | TTL | -## Optional capabilities - -Discovered by type assertion; only some providers implement these. - -### TableAttributes - -TableAttributes is an OPTIONAL capability, discovered by type assertion (like - -| Operation | Description | -| --- | --- | -| `TableAttributes` | | - ## Not in scope _Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/providers/oci/nosql/indexes.go b/providers/oci/nosql/indexes.go index 74ada7a85..50e208b4e 100644 --- a/providers/oci/nosql/indexes.go +++ b/providers/oci/nosql/indexes.go @@ -9,6 +9,8 @@ import ( ) // CreateIndex builds a secondary index on a table. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) CreateIndex(_ context.Context, table string, cfg driver.GSIConfig) (*driver.IndexInfo, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/providers/oci/nosql/nosql.go b/providers/oci/nosql/nosql.go index fdeb05a99..3ea638933 100644 --- a/providers/oci/nosql/nosql.go +++ b/providers/oci/nosql/nosql.go @@ -437,6 +437,8 @@ func (m *Mock) liveItems(t *tableData) []map[string]any { // CreateTable creates a table from the portable config. OCI is DDL-driven, so // the equivalent statement is synthesized and reported by GetTable; every // column takes OCI's STRING type, which is all the portable shape declares. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) CreateTable(_ context.Context, cfg driver.TableConfig) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/providers/oci/nosql/nosql_test.go b/providers/oci/nosql/nosql_test.go index ba0c8d62a..b48592c20 100644 --- a/providers/oci/nosql/nosql_test.go +++ b/providers/oci/nosql/nosql_test.go @@ -1355,3 +1355,50 @@ func TestSetMonitoringPublishesUnits(t *testing.T) { assert.Subset(t, names, []string{"ReadUnits", "WriteUnits"}) } + +// TestQueryOCINumericLiterals pins that a condition on a numeric column is +// compared by value, not by the text form the row and the literal happen to +// spell it in, while a STRING column stays a text comparison. +func TestQueryOCINumericLiterals(t *testing.T) { + m, _ := newMock(t) + ctx := context.Background() + + _, err := m.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: "CREATE TABLE readings (id STRING, level DOUBLE, code STRING, PRIMARY KEY (id))", + Limits: provisioned(), + }) + require.NoError(t, err) + + _, err = m.PutOCIRow(ctx, compartmentA, "readings", + map[string]any{"id": "r1", "level": float64(5), "code": "007"}, "") + require.NoError(t, err) + + tests := []struct { + name string + where string + match bool + }{ + {name: "a whole DOUBLE matches its fractional spelling", where: "level = 5.0", match: true}, + {name: "and its whole spelling", where: "level = 5", match: true}, + {name: "a different number does not match", where: "level = 5.5"}, + {name: "a STRING is compared by text", where: `code = "007"`, match: true}, + {name: "so a numerically equal STRING does not match", where: "code = 7"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rows, err := m.QueryOCI(ctx, compartmentA, "SELECT * FROM readings WHERE "+tc.where, 0) + require.NoError(t, err) + + if tc.match { + require.Len(t, rows, 1) + assert.Equal(t, "r1", rows[0]["id"]) + + return + } + + assert.Empty(t, rows) + }) + } +} diff --git a/providers/oci/nosql/query.go b/providers/oci/nosql/query.go index fdded80b0..80241d3fe 100644 --- a/providers/oci/nosql/query.go +++ b/providers/oci/nosql/query.go @@ -125,7 +125,7 @@ func (m *Mock) matchRows(t *tableData, conds []condition) ([]map[string]any, err continue } - if rowMatches(item, conds) { + if rowMatches(t, item, conds) { matched = append(matched, item) } } @@ -140,9 +140,23 @@ func sortedKeys(t *tableData) []string { return keys } -func rowMatches(item map[string]any, conds []condition) bool { +// rowMatches reports whether a row satisfies every condition. A condition on a +// numeric column is compared numerically: a DOUBLE holding 5 and the literal +// 5.0 are the same value, which their text forms are not. Every other column +// is compared by text, so a STRING "007" and the literal 7 stay apart. +func rowMatches(t *tableData, item map[string]any, conds []condition) bool { for _, c := range conds { - if fmt.Sprintf("%v", item[c.Column]) != c.Value { + got := fmt.Sprintf("%v", item[c.Column]) + + if isNumericColumn(t, c.Column) { + if compareStrings(got, c.Value) != 0 { + return false + } + + continue + } + + if got != c.Value { return false } } @@ -150,6 +164,21 @@ func rowMatches(item map[string]any, conds []condition) bool { return true } +// isNumericColumn reports whether a column's declared type orders numerically. +func isNumericColumn(t *tableData, name string) bool { + i := columnIndex(t, name) + if i < 0 { + return false + } + + switch t.Schema.Columns[i].Type { + case typeInteger, typeLong, typeFloat, typeDouble, typeNumber: + return true + } + + return false +} + // parseDML splits " [*] FROM table [WHERE conds]" and refuses the // clauses the mock does not run. func parseDML(stmt, verb string) (table string, conds []condition, err error) { diff --git a/providers/oci/nosql/rows.go b/providers/oci/nosql/rows.go index 99e78d93a..8f236ec72 100644 --- a/providers/oci/nosql/rows.go +++ b/providers/oci/nosql/rows.go @@ -99,6 +99,8 @@ func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map } // UpdateItem applies field-level updates to an existing row. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[string]any, error) { m.mu.Lock() defer m.mu.Unlock() @@ -203,6 +205,8 @@ func (m *Mock) Query(_ context.Context, input driver.QueryInput) (*driver.QueryR } // Scan returns every row, narrowed by filters. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) Scan(_ context.Context, input driver.ScanInput) (*driver.QueryResult, error) { m.mu.RLock() @@ -302,7 +306,10 @@ func matchesFilters(item map[string]any, filters []driver.ScanFilter) bool { } // compareOp applies one comparison, ordering numerically when both sides parse -// as numbers and lexically otherwise. +// as numbers and lexically otherwise. Equality is text equality: the portable +// driver carries no column types, so "5" and "5.0" cannot be told apart from a +// STRING pair that happens to look numeric. The OCI query endpoint, which does +// know the column type, compares numeric columns numerically — see rowMatches. func compareOp(val, op, want, end string) bool { switch op { case OpEqual: diff --git a/providers/oci/nosql/snapshot.go b/providers/oci/nosql/snapshot.go new file mode 100644 index 000000000..cd125a3bb --- /dev/null +++ b/providers/oci/nosql/snapshot.go @@ -0,0 +1,186 @@ +package nosql + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/internal/snapshot" + "github.com/stackshy/cloudemu/v2/services/database/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +var _ snapshot.Snapshottable = (*Mock)(nil) + +// nosqlSnapshot is the full serialized state of the OCI NoSQL mock. tables is +// dumped keyed by compartment and name, which is how it is addressed, and +// names by table OCID, so a table restores under both identities and the +// OCID cross-reference still resolves. The mutex, the *config.Options and the +// wired monitoring service are not serialized; the mock mints no lazy default +// or counter, so there is nothing else to carry. +type nosqlSnapshot struct { + Tables json.RawMessage `json:"tables,omitempty"` + Names json.RawMessage `json:"names,omitempty"` +} + +// Snapshot captures the mock's entire state as JSON. includeAssets is unused — +// NoSQL holds no bulk object bodies. +func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var snap nosqlSnapshot + + for _, d := range m.snapshotDumps(&snap) { + b, err := d.fn() + if err != nil { + return nil, fmt.Errorf("nosql: snapshot store: %w", err) + } + + *d.dst = b + } + + return json.Marshal(snap) +} + +// Restore rebuilds the mock's state under the original identities: every table +// keeps its OCID, its compartment and the rows it held. +func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { + var snap nosqlSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + return fmt.Errorf("nosql: parse snapshot: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, d := range m.snapshotDumps(&snap) { + if len(*d.dst) == 0 { + continue + } + + if err := d.load(*d.dst); err != nil { + return fmt.Errorf("nosql: restore store: %w", err) + } + } + + return nil +} + +// storeDump pairs a snapshot field with its store's dump and load functions, so +// Snapshot and Restore share one table and cannot drift apart. +type storeDump struct { + dst *json.RawMessage + fn func() ([]byte, error) + load func([]byte) error +} + +// snapshotDumps lists every store alongside the snapshot field it maps to. +func (m *Mock) snapshotDumps(snap *nosqlSnapshot) []storeDump { + return []storeDump{ + {&snap.Tables, m.tables.Snapshot, m.tables.LoadSnapshot}, + {&snap.Names, m.names.Snapshot, m.names.LoadSnapshot}, + } +} + +// tableDump is a table's serialized form. tableData's row store is a memstore, +// which JSON neither writes nor rebuilds, so the rows travel as a plain map and +// the store is remade from them on the way back; the attribute-based TTL and +// the compartment are unexported or nested for the same reason. +type tableDump struct { + ID string `json:"id"` + Name string `json:"name"` + DDLStatement string `json:"ddlStatement,omitempty"` + Schema Schema `json:"schema"` + Limits TableLimits `json:"limits"` + LifecycleState string `json:"lifecycleState,omitempty"` + TimeCreated string `json:"timeCreated,omitempty"` + TimeUpdated string `json:"timeUpdated,omitempty"` + IsAutoReclaimable bool `json:"isAutoReclaimable,omitempty"` + Scope scope.Scope `json:"scope"` + Tags map[string]string `json:"tags,omitempty"` + Indexes []*Index `json:"indexes,omitempty"` + TTL driver.TTLConfig `json:"ttl"` + Items map[string]map[string]any `json:"items,omitempty"` +} + +// MarshalJSON dumps a table and the rows it holds. +func (t *tableData) MarshalJSON() ([]byte, error) { + return json.Marshal(tableDump{ + ID: t.ID, + Name: t.Name, + DDLStatement: t.DDLStatement, + Schema: t.Schema, + Limits: t.Limits, + LifecycleState: t.LifecycleState, + TimeCreated: t.TimeCreated, + TimeUpdated: t.TimeUpdated, + IsAutoReclaimable: t.IsAutoReclaimable, + Scope: t.Scope, + Tags: t.Tags, + Indexes: t.Indexes, + TTL: t.ttl, + Items: t.items.All(), + }) +} + +// UnmarshalJSON rebuilds a table, remaking the row store the dump flattened. +// The store is always allocated, so a table restored from a dump that carried +// no rows is written to rather than nil-dereferenced. +func (t *tableData) UnmarshalJSON(data []byte) error { + var d tableDump + if err := json.Unmarshal(data, &d); err != nil { + return err + } + + *t = tableData{ + ID: d.ID, + Name: d.Name, + DDLStatement: d.DDLStatement, + Schema: d.Schema, + Limits: d.Limits, + LifecycleState: d.LifecycleState, + TimeCreated: d.TimeCreated, + TimeUpdated: d.TimeUpdated, + IsAutoReclaimable: d.IsAutoReclaimable, + Scope: d.Scope, + Tags: d.Tags, + Indexes: d.Indexes, + ttl: d.TTL, + items: memstore.New[map[string]any](), + } + + for key, item := range d.Items { + t.items.Set(key, retypeRow(&t.Schema, item)) + } + + return nil +} + +// retypeRow fits a decoded row back to the types its columns declare. JSON has +// one number type, so an INTEGER or LONG column comes back as a float64 unless +// it is coerced again; a value that no longer fits its column is left as it +// decoded rather than dropped, so a restore never loses a row silently. +func retypeRow(s *Schema, item map[string]any) map[string]any { + for i := range s.Columns { + col := &s.Columns[i] + + v, ok := item[col.Name] + if !ok || v == nil { + continue + } + + if typed, err := convertTyped(col, v); err == nil { + item[col.Name] = typed + } + } + + // The row expiry is bookkeeping rather than a declared column, and is + // compared as an integer. + if exp, ok := toUnix(item[ttlExpiryColumn]); ok { + item[ttlExpiryColumn] = exp + } + + return item +} diff --git a/providers/oci/nosql/snapshot_test.go b/providers/oci/nosql/snapshot_test.go new file mode 100644 index 000000000..c0bd50b91 --- /dev/null +++ b/providers/oci/nosql/snapshot_test.go @@ -0,0 +1,168 @@ +package nosql_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/nosql" + "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// TestSnapshotRestoreRoundTrip seeds two compartments, rows, an index, tags and +// an attribute TTL, snapshots, restores into a fresh mock and asserts every +// table comes back under its original OCID and compartment with its own rows — +// the row store nests inside each table, so it is the part a dump loses first. +func TestSnapshotRestoreRoundTrip(t *testing.T) { + ctx := t.Context() + src, _ := newMock(t) + + inA := createUsers(t, src) + + inB, err := src.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentB, + DDLStatement: usersDDL, + Limits: provisioned(), + FreeformTags: map[string]string{"env": "prod"}, + }) + require.NoError(t, err) + + _, err = src.CreateOCIIndex(ctx, compartmentA, "users", + nosql.IndexSpec{Name: "byName", Columns: []string{"name"}}, false) + require.NoError(t, err) + + require.NoError(t, src.UpdateTTL(ctx, "users", driver.TTLConfig{Enabled: true, AttributeName: "expiresAt"})) + + _, err = src.PutOCIRow(ctx, compartmentA, "users", + map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"}, "") + require.NoError(t, err) + + _, err = src.PutOCIRow(ctx, compartmentB, "users", + map[string]any{"id": float64(2), "email": "b@example.com", "name": "Bea"}, "") + require.NoError(t, err) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst, _ := newMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + // Both tables restored under their own OCID and compartment. + gotA, err := dst.GetOCITable(ctx, compartmentA, "users") + require.NoError(t, err) + assert.Equal(t, inA.ID, gotA.ID) + assert.Equal(t, []string{"id", "email"}, gotA.Schema.PrimaryKey) + + gotB, err := dst.GetOCITable(ctx, compartmentB, "users") + require.NoError(t, err) + assert.Equal(t, inB.ID, gotB.ID) + assert.Equal(t, map[string]string{"env": "prod"}, gotB.FreeformTags) + + // Addressable by OCID too, so the names store round-tripped. + byOCID, err := dst.GetOCITable(ctx, compartmentB, inB.ID) + require.NoError(t, err) + assert.Equal(t, compartmentB, byOCID.CompartmentID) + + // The index survived. + indexes, err := dst.ListOCIIndexes(ctx, compartmentA, "users", "") + require.NoError(t, err) + require.Len(t, indexes, 1) + assert.Equal(t, "byName", indexes[0].Name) + + // The attribute-based TTL is unexported on the table, so it needs the + // custom JSON methods to travel at all. + ttl, err := dst.DescribeTTL(ctx, "users") + require.NoError(t, err) + assert.True(t, ttl.Enabled) + assert.Equal(t, "expiresAt", ttl.AttributeName) + + // Each table's own rows came back, typed as their columns declare rather + // than as the float64 JSON decodes every number to. + row, err := dst.GetOCIRow(ctx, compartmentA, "users", map[string]string{"id": "1", "email": "a@example.com"}) + require.NoError(t, err) + assert.Equal(t, int64(1), row.Value["id"]) + assert.Equal(t, "Ada", row.Value["name"]) + + row, err = dst.GetOCIRow(ctx, compartmentB, "users", map[string]string{"id": "2", "email": "b@example.com"}) + require.NoError(t, err) + assert.Equal(t, "Bea", row.Value["name"]) + + // A row is not visible from the other compartment's table. + _, err = dst.GetOCIRow(ctx, compartmentA, "users", map[string]string{"id": "2", "email": "b@example.com"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +// TestSnapshotRestoreKeepsRowExpiry pins that a row written under a table-level +// TTL still expires at the time it was stamped with, rather than losing its +// expiry to the dump or coming back as an already-expired float. +func TestSnapshotRestoreKeepsRowExpiry(t *testing.T) { + ctx := t.Context() + src, clock := newMock(t) + + _, err := src.CreateOCITable(ctx, nosql.TableSpec{ + CompartmentID: compartmentA, + DDLStatement: "CREATE TABLE sessions (id STRING, PRIMARY KEY (id)) USING TTL 2 DAYS", + Limits: provisioned(), + }) + require.NoError(t, err) + + written, err := src.PutOCIRow(ctx, compartmentA, "sessions", map[string]any{"id": "s1"}, "") + require.NoError(t, err) + require.NotEmpty(t, written.TimeOfExpiration) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst, dstClock := newMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + row, err := dst.GetOCIRow(ctx, compartmentA, "sessions", map[string]string{"id": "s1"}) + require.NoError(t, err) + assert.Equal(t, written.TimeOfExpiration, row.TimeOfExpiration) + + // And it still expires on time on the far side. + clock.Advance(3 * 24 * time.Hour) + dstClock.Advance(3 * 24 * time.Hour) + + _, err = dst.GetOCIRow(ctx, compartmentA, "sessions", map[string]string{"id": "s1"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +// TestSnapshotRestoreEmptyAndMalformed confirms an empty mock round-trips +// cleanly and that a restored table's row store is usable rather than nil. +func TestSnapshotRestoreEmptyAndMalformed(t *testing.T) { + ctx := t.Context() + src, _ := newMock(t) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst, _ := newMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + names, err := dst.ListTables(ctx) + require.NoError(t, err) + assert.Empty(t, names) + + // A table with no rows restores with a store that can be written to. + createUsers(t, src) + + data, err = src.Snapshot(ctx, false) + require.NoError(t, err) + + dst, _ = newMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + _, err = dst.PutOCIRow(ctx, compartmentA, "users", + map[string]any{"id": float64(1), "email": "a@example.com", "name": "Ada"}, "") + require.NoError(t, err) + + // Malformed input is reported, not panicked on. + for _, bad := range []string{"", "not json", `{"tables":5}`, `{"names":[1,2]}`} { + require.Error(t, dst.Restore(ctx, json.RawMessage(bad))) + } +}