The DynamoDB library for Clojure
(This is a copy of the readme file from the repository.)
This library is a driver for DynamoDB written in pure Clojure. No AWS SDK, lightweight dependencies, GraalVM-friendly.
Benefits
- Free from AWS SDK. Everything is implemented with pure JSON + HTTP.
 - Quite narrow dependencies: just HTTP Kit and Cheshire.
 - Compatible with Native Image! Thus, easy to use as a binary file in AWS Lambda.
 - Clojure-friendly: supports fully qualified keyword attributes and handles properly them in SQL expressions.
 - Both encoding & decoding are extendable with protocols & multimethods.
 - Raw API access for special cases.
 - Specs for better input validation.
 - Compatible with Yandex DB.
 
Installation
Leiningen/Boot:
[com.github.igrishaev/dynamodb "0.1.2"]
Clojure CLI/deps.edn:
com.github.igrishaev/dynamodb {:mvn/version "0.1.2"}
Documentation
At cljdoc.org (automatic build).
API Implemented
At the moment, only the most important API targets are implemented. The rest of them is a matter of time and copy-paste. Let me know if you need something missing in the table below.
| Target | Done? | Comment | 
|---|---|---|
| BatchExecuteStatement | ||
| BatchGetItem | + | |
| BatchWriteItem | ||
| CreateBackup | + | |
| CreateGlobalTable | ||
| CreateTable | + | |
| DeleteBackup | ||
| DeleteItem | + | |
| DeleteTable | + | |
| DescribeBackup | + | |
| DescribeContinuousBackups | ||
| DescribeContributorInsights | ||
| DescribeEndpoints | ||
| DescribeExport | ||
| DescribeGlobalTable | ||
| DescribeGlobalTableSettings | ||
| DescribeImport | ||
| DescribeKinesisStreamingDestination | ||
| DescribeLimits | ||
| DescribeTable | + | |
| DescribeTableReplicaAutoScaling | ||
| DescribeTimeToLive | ||
| DisableKinesisStreamingDestination | ||
| EnableKinesisStreamingDestination | ||
| ExecuteStatement | ||
| ExecuteTransaction | ||
| ExportTableToPointInTime | ||
| GetItem | + | |
| ImportTable | ||
| ListBackups | ||
| ListContributorInsights | ||
| ListExports | ||
| ListGlobalTables | ||
| ListImports | ||
| ListTables | + | |
| ListTagsOfResource | ||
| PutItem | + | |
| Query | + | |
| RestoreTableFromBackup | ||
| RestoreTableToPointInTime | ||
| Scan | + | |
| TagResource | + | |
| TransactGetItems | ||
| TransactWriteItems | ||
| UntagResource | ||
| UpdateContinuousBackups | ||
| UpdateContributorInsights | ||
| UpdateGlobalTable | ||
| UpdateGlobalTableSettings | ||
| UpdateItem | + | |
| UpdateTable | ||
| UpdateTableReplicaAutoScaling | ||
| UpdateTimeToLive | 
Who Uses It
DynamoDB is a part of Teleward — a Telegram captcha bot. The bot is hosted in Yandex Cloud as a binary file compiled with GraalVM. It uses the library to track the state in Yandex DB. In turn, Yandex DB is a cloud database that mimics DynamoDB and serves a subset of its HTTP API.
Usage
First, import the library:
(require '[dynamodb.api :as api])
(require '[dynamodb.constant :as const])
The constant module is needed sometimes to refer to common DynamoDB values
like "PAY_PER_REQUEST", "PROVISIONED" and so on.
The Client
Prepare a client object. The first four parameters are mandatory:
(def CLIENT
  (api/make-client "aws-public-key"
                   "aws-secret-key"
                   "https://aws.dynamodb.endpoint.com/some/path"
                   "aws-region"
                   {...}))
For Yandex DB, the region is something like “ru-central1”.
Both public and secret AWS keys are masked with a special wrapper that prevents them from being logged or printed.
The fifth parameter is a map of options to override:
| Parameter | Default | Description | 
|---|---|---|
:throw? | 
      true | 
      Whether to throw a negative DynamoDB response. | 
:version | 
      "20120810" | 
      DynamoDB API version. | 
:http-opt | 
      (see below) | A map of HTTP Kit default settings. | 
The default HTTP settings are:
{:user-agent "com.github.igrishaev/dynamodb"
 :keepalive (* 30 1000)
 :insecure? true
 :follow-redirects false}
Create a Table
To create a new table, pass its name, the schema map, and the primary key mapping:
(api/create-table CLIENT
                  "SomeTable"
                  {:user/id :N
                   :user/name :S}
                  {:user/id const/key-type-hash
                   :user/name const/key-type-range}
                  {:tags {:foo "hello"}
                   :table-class const/table-class-standard
                   :billing-mode const/billing-mode-pay-per-request})
List Tables
Tables can be listed by pages. The default page size is 100. Once you’ve reached
the limit, check out the LastEvaluatedTableName field. Pass it to the
:start-table optional argument to propagate to the next page:
(def resp1
  (api/list-tables CLIENT {:limit 10}))
(def last-table
  (:LastEvaluatedTableName resp1))
(def resp2
  (api/list-tables CLIENT
                   {:limit 10
                    :start-table last-table}))
Put Item
To upsert an item, pass a map that contains the primary attributes:
(api/put-item CLIENT
              "SomeTable"
              {:user/id 1
               :user/name "Ivan"
               :user/foo 1}
              {:return-values const/return-values-none})
Pass :sql-condition to make the operation conditional. In the example above,
the :user/foo attribute is 1. The second upsert operation checks if
:user/foo is either 1, 2, or 3, which is true. Thus, it will fail:
(api/put-item CLIENT
              "SomeTable"
              {:user/id 1
               :user/name "Ivan"
               :user/test 3}
              {:sql-condition "#foo in (:one, :two, :three)"
               :attr-names {"#foo" :user/foo}
               :attr-values {":one" 1
                             ":two" 2
                             ":three" 3}
               :return-values const/return-values-all-old})
Get Item
To get an item, provide its primary key:
(api/get-item CLIENT
              "SomeTable"
              {:user/id 1
               :user/name "Ivan"})
{:Item #:user{:id 1
              :name "Ivan"
              :foo 1}}
There is an option to get only the attributes you need or even sub-attributes for nested maps or lists:
;; put some complex values
(api/put-item CLIENT
              "SomeTable"
              {:user/id 1
               :user/name "Ivan"
               :test/kek "123"
               :test/foo 1
               :abc nil
               :foo "lol"
               :bar {:baz [1 2 3]}})
;; pass a list of attributes/paths into the `:attrs-get` param
(api/get-item CLIENT
              "SomeTable"
              {:user/id 1
               :user/name "Ivan"}
              {:attrs-get [:test/kek "bar.baz[1]" "abc" "foo"]})
;; the result:
{:Item {:test/kek "123"
        :bar {:baz [2]}
        :abc nil
        :foo "lol"}}
Update Item
This operation is the most complex. In AWS SDK or Faraday, to update an item’s secondary attributes, one should manually build a SQL expression that involves string formatting, concatenation and similar boring stuff.
SET username = :username, email = :email, ...
The ADD, DELETE, and REMOVE expressions require manual work as well.
The present library solves this problem for you. The update-item function
accepts :add, :set, :delete, and :remove parameters, either maps or
vectors.
The :sql-condition argument accepts a plain SQL expression. Should it
evaluates as falseness, the item won’t be affected and you’ll get a negative
response.
Set Attributes
(api/update-item CLIENT
                 table
                 {:user/id 1
                  :user/name "Ivan"}
                 {:attr-names {"#counter" :test/counter}
                  :attr-values {":one" 1}
                  :set {"Foobar" 123
                        :user/email "test@test.com"
                        "#counter" (api/sql "#counter + :one")}})
The example above covers three various options for the :set argument. Namely:
- The attribute is a plain string 
("Foobar"), and the value is plain as well. - The attribute is a complex keyword (
:user/email) which cannot be placed in a SQL expression directly. Under the hood, the library produces an alias for it and injects it intoExpressionAttributeNames. - The attribute is an alias, and the value is a raw expression. To distinguish
an expression from a regular string (e.g. email), there is a wrapper
api/sql. The alias#countershould be declared in the:attr-namesmap. 
Add Attributes
The :add parameter accepts a map of an attribute or an alias to a
value. Imagine you have the following item in the db:
{:user/id 1
 :user/name "Ivan"
 :amount 3
 :test/colors #{"r" "g"}}
To increase the amount and add a new color into the colors set, perfrom:
(api/update-item CLIENT
                 table
                 {:user/id 1
                  :user/name "Ivan"}
                 {:add {"amount" 1
                        :test/colors #{"b"}}})
Result:
{:Item
 {:amount 4
  :user/id 1
  :test/colors #{"b" "r" "g"}
  :user/name "Ivan"}}
Remove Attributes
To remove an attribute, pass the :remove vector. Each item of that vector is
either a keyword attribute, a raw string expression, or an alias.
(api/update-item CLIENT
                 table
                 {:user/id 1
                  :user/name "Ivan"}
                 {:attr-names {"#kek" :test/kek}
                  :remove ["#kek" "abc" :test/lol]})
To remove an item from a list, pass a string like this:
;; item in the databalse
{:tags ["foo" "bar" "baz"]}
{:remove ["tags[1]"]}
Use an alias when the attribute is a keyword with a namespace:
(api/update-item CLIENT
                 table
                 {:user/id 1
                  :user/name "Ivan"}
                 {:attr-names {"#tags" :user/tags}
                  :remove ["#tags[1]"]})
Delete Attributes
In DynamoDB, the DELETE clause is used to remove items from sets. The
update-item function accepts the :delete argument which is a map. The key is
either a keyword or a string alias. The value is always a set:
The item:
{:user/id 1
 :user/name "Ivan"
 :user/colors #{"r" "g" "b"}}
API call:
(api/update-item CLIENT
                 table
                 {:user/id 1
                  :user/name "Ivan"}
                 {:delete {:user/colors #{"r" "b"}}})
Result:
{:Item #:user{:colors #{"g"} :id 1 :name "Ivan"}}
Delete Item
Simple deletion of an item:
(api/delete-item CLIENT
                 table
                 {:user/id 1 :user/name "Ivan"})
Conditional deletion: throws an exception when the expression fails.
(api/put-item CLIENT
              table
              {:user/id 1
               :user/name "Ivan"
               :test/kek 99})
(api/delete-item CLIENT
                 table
                 {:user/id 1 :user/name "Ivan"}
                 {:sql-condition "#kek in (:foo, :bar, :baz)"
                  :attr-names {"#kek" :test/kek}
                  :attr-values {":foo" 1
                                ":bar" 2
                                ":baz" 3}})
In the example above, the "#kek in (:foo, :bar, :baz)" expression fails as the
:test/kek attribute is of value 99. The item stays in the database, and you’ll
get an exception with ex-info:
{:error? true
 :status 400
 :path "com.amazonaws.dynamodb.v20120810"
 :exception "ConditionalCheckFailedException"
 :message "The conditional request failed"
 :payload
 {:TableName table
  :Key #:user{:id {:N "1"} :name {:S "Ivan"}}
  :ConditionExpression "#kek in (:foo, :bar, :baz)"
  :ExpressionAttributeNames {"#kek" :test/kek}
  :ExpressionAttributeValues
  {":foo" {:N "1"} ":bar" {:N "2"} ":baz" {:N "3"}}}
 :target "DeleteItem"}
Query
The Query target allows searching items that match a primary key partially or
match some range. Imagine the primary key of a table is :user/id :HASH and
:user/name :RANGE. Here is what you have in the database:
{:user/id 1
 :user/name "Ivan"
 :test/foo 1}
{:user/id 1
 :user/name "Juan"
 :test/foo 2}
{:user/id 2
 :user/name "Huan"
 :test/foo 3}
Now, to find the items whose :user/id is 1, execute:
(api/query CLIENT
           table
           {:sql-key "#id = :one"
            :attr-names {"#id" :user/id}
            :attr-values {":one" 1}
            :limit 1})
Result:
{:Items [{:user/id 1
          :test/foo 1
          :user/name "Ivan"}]
 :Count 1
 :ScannedCount 1
 :LastEvaluatedKey #:user{:id 1
                          :name "Ivan"}}
To propagate to the next page, fetch the LastEvaluatedKey field from the
result and pass it into the :start-key Query parameter.
Scan
The Scan API goes through the whole table collecting the items that match an expression. This is not optimal yet required sometimes.
(api/scan CLIENT
          table
          {:sql-filter "#foo = :two"
           :attrs-get [:test/foo "#name"]
           :attr-names {"#foo" :test/foo
                        "#name" :user/name}
           :attr-values {":two" 2}
           :limit 2})
Result:
{:Items [{:test/foo 2
          :user/name "Ivan"}]
 :Count 1
 :ScannedCount 2
 :LastEvaluatedKey #:user{:id 1
                          :name "Ivan"}}
Both LastEvaluatedKey and :start-key parameters work as described above.
Other API
See the tests, specs, and dynamodb.api module for more information.
Raw API access
The api-call function allows you to interact with DynamoDB on a low level. It
accepts the client, the target name, and a raw payload you’d like to send to
DB. The payload gets sent as-is with no kind of processing or interference.
(api/api-call CLIENT
             "NotImplementedTarget"
             {:ParamFoo ... :ParamBar ...})
Specs
The library provides a number of specs for the API. Find them in the
dynamodb.spec module. It’s not imported by default to prevent the binary file
from growing when compiled with GraalVM. That’s a known issue when introducing
clojure.spec adds +20 Mbs to the file.
Still, those specs are useful for testing and documentation. Import the specs,
then instrument the functions by calling the instrument function:
(require 'dynamodb.spec)
(require '[clojure.spec.test.alpha :as spec.test])
(spec.test/instrument)
Now if you pass something wrong into one of the library functions, you’ll get a spec exception.
Tests
The primary testing module called api_test.clj relies on a local DynamoDB
instance running in Docker. To bootstrap it, execute the command:
make docker-up
It spawns amazon/dynamodb-local image on port 8000. Now connect to the REPL
and run the API tests from your editor as usual.
Ivan Grishaev, 2023
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter