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/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..bf801639d --- /dev/null +++ b/docs/coverage/oci/nosql.md @@ -0,0 +1,37 @@ + +# 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 | + +## 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..be201e99c --- /dev/null +++ b/providers/oci/nosql/index_extras.go @@ -0,0 +1,109 @@ +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, compartmentID, nameOrID string, spec IndexSpec, ifNotExists bool, +) (*Index, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(compartmentID, 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, compartmentID, nameOrID, indexName string) (*Index, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(compartmentID, 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, compartmentID, nameOrID, indexName string) ([]Index, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(compartmentID, 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, compartmentID, nameOrID, indexName string, ifExists bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(compartmentID, 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 +} + +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..50e208b4e --- /dev/null +++ b/providers/oci/nosql/indexes.go @@ -0,0 +1,276 @@ +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. +// +//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() + + 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..3ea638933 --- /dev/null +++ b/providers/oci/nosql/nosql.go @@ -0,0 +1,568 @@ +// 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" + "strings" + "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 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 store key, 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) +} + +// 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) + } + + 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. 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(compartmentID, 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. +// +//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() + + 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(m.opts.CompartmentID, 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 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() + 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: compartmentID}, + items: memstore.New[map[string]any](), + } + + m.tables.Set(key, t) + m.names.Set(t.ID, key) + + 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() + + t, err := m.lookup("", name) + if err != nil { + return err + } + + 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) + if err != nil { + return nil, err + } + + cfg := toTableConfig(t) + + return &cfg, nil +} + +// 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() + + 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 new file mode 100644 index 000000000..b48592c20 --- /dev/null +++ b/providers/oci/nosql/nosql_test.go @@ -0,0 +1,1404 @@ +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" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/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(), compartmentA, 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(), compartmentA, "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(), compartmentA, "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(), compartmentA, "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(), compartmentA, 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(), compartmentA, table.ID)) + + _, 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(), compartmentA, "users"))) +} + +func TestOCIRowRoundTrip(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, 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(), compartmentA, "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(), compartmentA, "users", + map[string]string{"id": "1", "email": "a@example.com"}) + require.NoError(t, err) + assert.True(t, deleted) + + _, err = m.GetOCIRow(context.Background(), compartmentA, "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(), compartmentA, "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(), compartmentA, "users", row, nosql.OptionIfPresent) + require.Error(t, err) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + _, err = m.PutOCIRow(context.Background(), compartmentA, "users", row, nosql.OptionIfAbsent) + require.NoError(t, err) + + _, 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(), compartmentA, "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(), compartmentA, "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(), compartmentA, "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(), compartmentA, "sessions", map[string]any{"id": "s1"}, "") + require.NoError(t, err) + assert.NotEmpty(t, written.TimeOfExpiration) + + 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. + assert.Len(t, row.Value, 1) + + clock.Advance(3 * 24 * time.Hour) + + _, err = m.GetOCIRow(context.Background(), compartmentA, "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(), 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(), compartmentA, "users", spec, false) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + 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(), compartmentA, "users", "byName") + require.NoError(t, err) + assert.Equal(t, "byName", got.Name) + + list, err := m.ListOCIIndexes(context.Background(), compartmentA, "users", "") + require.NoError(t, err) + require.Len(t, list, 1) + + require.NoError(t, m.DeleteOCIIndex(context.Background(), compartmentA, "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(), compartmentA, "users", "byName", true)) +} + +func TestCreateOCIIndexRejectsUndeclaredColumn(t *testing.T) { + m, _ := newMock(t) + createUsers(t, m) + + _, err := m.CreateOCIIndex(context.Background(), compartmentA, "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(), compartmentA, "users", + map[string]any{"id": float64(1), "email": email, "name": "Ada"}, "") + require.NoError(t, err) + } + + _, err := m.PutOCIRow(context.Background(), compartmentA, "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, 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) + + 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)) +} + +// 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) + + _, 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"}) +} + +// 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 new file mode 100644 index 000000000..80241d3fe --- /dev/null +++ b/providers/oci/nosql/query.go @@ -0,0 +1,317 @@ +package nosql + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// 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.lookup(compartmentID, table) + 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.lookup(compartmentID, table) + 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 +} + +// 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(t, item, conds) { + matched = append(matched, item) + } + } + + return matched, nil +} + +func sortedKeys(t *tableData) []string { + keys := t.items.Keys() + sort.Strings(keys) + + return keys +} + +// 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 { + 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 + } + } + + 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) { + 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..8e205bf89 --- /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, compartmentA, "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, compartmentA, "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, compartmentA, "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, compartmentA, "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, compartmentA, "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, compartmentA, "users") + if assert.NoError(t, err) { + assert.NotEmpty(t, table.Schema.PrimaryKey) + } + }() + + go func() { + defer wg.Done() + + _, err := m.ListOCIIndexes(ctx, compartmentA, "users", "") + assert.NoError(t, err) + }() + } + + wg.Wait() + + table, err := m.GetOCITable(ctx, compartmentA, "users") + require.NoError(t, err) + assert.Len(t, table.Schema.Columns, 3+raceGoroutines) + + indexes, err := m.ListOCIIndexes(ctx, compartmentA, "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, compartmentA, "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..6c85314e9 --- /dev/null +++ b/providers/oci/nosql/row_extras.go @@ -0,0 +1,298 @@ +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, compartmentID, nameOrID string, key map[string]string) (*Row, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(compartmentID, 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, compartmentID, nameOrID string, value map[string]any, option string, +) (*Row, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(compartmentID, 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, compartmentID, nameOrID string, key map[string]string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(compartmentID, 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..8f236ec72 --- /dev/null +++ b/providers/oci/nosql/rows.go @@ -0,0 +1,431 @@ +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. +// +//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() + + 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. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. +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. 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: + 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/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))) + } +} diff --git a/providers/oci/nosql/table_extras.go b/providers/oci/nosql/table_extras.go new file mode 100644 index 000000000..3809e2fd7 --- /dev/null +++ b/providers/oci/nosql/table_extras.go @@ -0,0 +1,297 @@ +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(tableKey(spec.CompartmentID, d.Table)); ok { + if d.IfNotExists { + return ptr(toTable(existing)), nil + } + + return nil, cerrors.Newf(cerrors.AlreadyExists, + "table %q already exists in compartment %q", d.Table, spec.CompartmentID) + } + + t, err := m.newTable(spec.CompartmentID, d.Table, normaliseStatement(spec.DDLStatement), &d.Schema, limits) + if err != nil { + return nil, err + } + + 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, compartmentID, nameOrID string) (*Table, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.resolve(compartmentID, 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} + keys := m.tables.Keys() + out := make([]Table, 0, len(keys)) + + for _, k := range keys { + t, ok := m.tables.Get(k) + if !ok || !t.Scope.Matches(filter) { + continue + } + + if name != "" && t.Name != name { + continue + } + + 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, compartmentID, nameOrID string, upd TableUpdate) (*Table, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(compartmentID, 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, compartmentID, nameOrID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.resolve(compartmentID, nameOrID) + if err != nil { + return err + } + + m.dropTable(t) + + return nil +} + +// 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 toCompartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "toCompartmentId is required") + } + + t, err := m.resolve(compartmentID, nameOrID) + if err != nil { + return err + } + + 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 +} + +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..72e5cbdba --- /dev/null +++ b/server/oci/nosql/handler.go @@ -0,0 +1,313 @@ +// 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. 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, compartmentID, nameOrID string) (*nosqlprovider.Table, error) + ListOCITables(ctx context.Context, compartmentID, name string) ([]nosqlprovider.Table, error) + UpdateOCITable( + ctx context.Context, compartmentID, nameOrID string, upd nosqlprovider.TableUpdate, + ) (*nosqlprovider.Table, error) + DeleteOCITable(ctx context.Context, compartmentID, nameOrID string) error + ChangeOCITableCompartment(ctx context.Context, compartmentID, nameOrID, toCompartmentID string) error + + CreateOCIIndex( + ctx context.Context, compartmentID, nameOrID string, spec nosqlprovider.IndexSpec, ifNotExists bool, + ) (*nosqlprovider.Index, 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, compartmentID, nameOrID string, key map[string]string, + ) (*nosqlprovider.Row, error) + PutOCIRow( + ctx context.Context, compartmentID, nameOrID string, value map[string]any, option string, + ) (*nosqlprovider.Row, 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) +} + +// 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..97c3f6c66 --- /dev/null +++ b/server/oci/nosql/handler_test.go @@ -0,0 +1,1131 @@ +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 +} + +// 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 new file mode 100644 index 000000000..6c0981288 --- /dev/null +++ b/server/oci/nosql/index.go @@ -0,0 +1,148 @@ +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) + } + + 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(), compartmentID, 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) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + if _, err := h.findTable(r, compartmentID, tableID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + indexes, err := h.extras.ListOCIIndexes(r.Context(), compartmentID, 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) { + 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(), compartmentID, 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 + } + + compartmentID := ocirest.CompartmentID(r) + + table, err := h.findTable(r, compartmentID, tableID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ifExists := r.URL.Query().Get("isIfExists") == "true" + + if err := h.extras.DeleteOCIIndex(r.Context(), compartmentID, 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..5e58c3a51 --- /dev/null +++ b/server/oci/nosql/row.go @@ -0,0 +1,132 @@ +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 + } + + 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(), compartmentID, 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 + } + + 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(), compartmentID, 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 + } + + 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(), compartmentID, tableID, key) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, deleteRowResult{IsSuccess: deleted}) +} + +// 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..584b8f4cf --- /dev/null +++ b/server/oci/nosql/table.go @@ -0,0 +1,221 @@ +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.CompartmentID, 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, ocirest.CompartmentID(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(), compartmentOf(r, req.CompartmentID), 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 + } + + 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(), compartmentID, 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 + } + + 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(), compartmentID, 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 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 != "" && 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 new file mode 100644 index 000000000..6d88904c6 --- /dev/null +++ b/server/oci/nosql/types.go @@ -0,0 +1,198 @@ +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 { + 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. +// 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 { + FromCompartmentID string `json:"fromCompartmentId"` + 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) +}