deta: Functional Database Mapping
(require deta) | package: deta-lib |
This library automatically maps database tables to Racket structs and lets you perform CRUD operations and arbitrary queries on them. Sort of like an ORM.
The API is currently stable, but small things may change before 1.0. Watch the GitHub repository if you want to stay on top of potential changes.
1 Principles
The main principle behind this library is "explicitness without tedium." By that I mean that it should be clear to someone who is reading code that uses this library how that code maps to database operations, while making the common cases of mapping data between database tables and Racket structs straightforward and simple.
1.1 Non-goals
Support for every SQL dialect. Despite the fact that both SQLite and PostgreSQL are currently supported by this library, the focus is on PostgreSQL and SQLite just serves as a check to ensure that nothing is too PostgreSQL specific.
Being a general purpose SQL DSL. For some queries you may have to resort to raw SQL or the sql library. deta is very much an "80% solution."
Being externally-extensible. The SQL AST as well as all of the dialect code is considered private and any new dialects (such as MySQL) will have to be added to the library itself.
If you’re down with that, then, by all means, carry on and read the tutorial!
2 Compared to ...
2.1 Racquel
Racquel takes a more classic approach to database mapping by being a real ORM. It is based on the class system, with entities backed by mutable objects and with support for associations via lazy loading. Deta’s approach is the opposite of this by focusing on working with immutable structs, avoiding associations and any sort of lazy behavior altogether.
2.2 sql
The sql library is great at statically generating SQL queries. The problem is that the generated queries are not composable at runtime. You have to write macros to compose them and I’ve found that that gets tedious quickly.
On top of giving you composable queries – as you can hopefully see from the tutorial –, deta also automatically maps CRUD operations to structs, which is out of scope for sql.
3 Tutorial
deta builds upon the db library. You will use deta to generate your mappings and create queries, but the db library will be doing the actual work of talking to the database and handling transactions.
Let’s start by creating a database connection in the usual way:
> (require db) >
> (define conn (postgresql-connect #:database "deta" #:user "deta" #:password "deta"))
Next, let’s define a schema for books:
> (require deta) >
> (define-schema book ([id id/f #:primary-key #:auto-increment] [title string/f #:contract non-empty-string? #:wrapper string-titlecase] [author string/f #:contract non-empty-string?] [published-on date/f]))
The above generates a struct named book with fields for the table’s id, title, author and published-on columns, an associated “smart constructor” called make-book and functional setter and updater functions for each field.
> (require gregor) >
> (define a-book (make-book #:title "To Kill a Mockingbirb" #:author "Harper Lee" #:published-on (date 1960 7 11))) > > (book-id a-book) #<sql-null>
> (book-title a-book) "To Kill A Mockingbirb"
> (book-title (update-book-title a-book (lambda (t) (string-append t "?")))) "To Kill A Mockingbirb?"
> ; schema entities are immutable so the above did not change a-book > (book-title a-book) "To Kill A Mockingbirb"
We can use the schema to issue DDL commands to the database and create the table:
> (create-table! conn 'book)
While the DDL functionality is convenient for the purposes of this tutorial, in real world projects you should probably use something like north to manage your database schema.
Now that we have a table, we can insert the book that we created into the database:
> (define saved-book (insert-one! conn a-book)) > > (book-id saved-book) 1
Oops! Looks like we have a typo in the book’s title:
> (book-title saved-book) "To Kill A Mockingbirb"
Let’s update the book and fix it:
> (define updated-book (update-one! conn (set-book-title saved-book "To Kill a Mockingbird"))) > (book-title saved-book) "To Kill A Mockingbirb"
> (book-title updated-book) "To Kill A Mockingbird"
That’s better! Let’s insert a few more books:
> (void (insert! conn (make-book #:title "1984" #:author "George Orwell" #:published-on (date 1949 6 8)) (make-book #:title "The Lord of the Rings" #:author "J.R.R. Tolkien" #:published-on (date 1954 7 29)) (make-book #:title "The Catcher in the Rye" #:author "J.D. Salinger" #:published-on (date 1949 7 16))))
And now let’s query for all of the books published before 1955:
> (require threading) >
> (for/list ([b (in-entities conn (~> (from book #:as b) (where (< b.published-on (date "1955-01-01"))) (order-by ([b.published-on #:desc]))))]) (book-title b)) '("The Lord Of The Rings" "The Catcher In The Rye" "1984")
Sweet! Here’s the query we just ran:
> (displayln (~> (from book #:as b) (where (< b.published-on (date "1955-01-01"))) (order-by ([b.published-on #:desc])))) #<query: SELECT b.id, b.title, b.author, b.published_on FROM books AS b WHERE b.published_on < (DATE '1955-01-01') ORDER BY b.published_on DESC>
What about dynamic parameters, you may ask? Let’s wrap the above query in a function:
> (define (books-before year) (~> (from book #:as b) (where (< b.published-on ,(sql-date year 1 1))) (order-by ([b.published-on #:desc])))) >
> (for/list ([b (in-entities conn (books-before 1950))]) (book-title b)) '("The Catcher In The Rye" "1984")
>
> (for/list ([b (in-entities conn (books-before 1955))]) (book-title b)) '("The Lord Of The Rings" "The Catcher In The Rye" "1984")
Any time the query combinators encounter an unquote, that value gets replaced with a placeholder node in the query AST and, when the query is eventually executed, the value is bound to its prepared statement. This makes it safe and easy to parameterize your queries without having to worry about SQL injection attacks.
Oftentimes, you’ll want to query data from the DB that doesn’t match your schema. For example, let’s say we want to grab the number of books published by year from our database. To do that, we can declare a virtual schema – one whose entities can’t be persisted – and project our queries onto that schema.
> (define-schema book-stats #:virtual ([year date/f] [books integer/f])) >
> (define books-published-by-year (~> (from book #:as b) (select (as (cast (date_trunc "year" b.published-on) date) year) (count b.title)) (group-by year) (order-by ([year])) (project-onto book-stats-schema))) >
> (for ([s (in-entities conn books-published-by-year)]) (displayln (format "year: ~a books: ~a" (book-stats-year s) (book-stats-books s))))
year: #<date 1949-01-01> books: 2
year: #<date 1954-01-01> books: 1
year: #<date 1960-01-01> books: 1
If we hadn’t wrapped our query with project-onto, then the data would’ve been returned as values which we could destructure inside the for loop, exactly like in-query from db.
It’s time to wind things down so let’s delete all the books published before 1950:
> (query-exec conn (delete (books-before 1950)))
Re-run the last query to make sure it worked:
> (for ([s (in-entities conn books-published-by-year)]) (displayln (format "year: ~a books: ~a" (book-stats-year s) (book-stats-books s))))
year: #<date 1954-01-01> books: 1
year: #<date 1960-01-01> books: 1
That’s all there is to it. You now know the basics of deta. Thanks for following along! If you want to learn more be sure to check out the reference documentation below.
4 Reference
4.1 Query
4.1.1 DDL
procedure
(create-all! conn) → void?
conn : connection?
Added in version 0.14 of package deta-lib.
procedure
conn : connection?
Added in version 0.14 of package deta-lib.
procedure
(create-table! conn schema) → void?
conn : connection? schema : (or/c schema? symbol?)
procedure
(drop-table! conn schema) → void?
conn : connection? schema : (or/c schema? symbol?)
4.1.2 Entity CRUD
procedure
conn : connection? e : entity?
Raises a user error if any of the entities are based on virtual schemas.
procedure
(insert-one! conn e) → (or/c #f entity?)
conn : connection? e : entity?
Equivalent to:
(match (insert! conn e) [(list e) e] [_ #f])
procedure
(in-entities conn q [#:batch-size batch-size]) → sequence?
conn : connection? q : query? batch-size : (or/c exact-positive-integer? +inf.0) = +inf.0
#:batch-size controls how many rows to fetch from the database at a time. It is analogous to in-query’s #:fetch argument.
procedure
(query-entities conn q [ #:batch-size batch-size]) → (listof entity?) conn : connection? q : query? batch-size : (or/c exact-positive-integer? +inf.0) = +inf.0
Added in version 0.14 of package deta-lib.
procedure
conn : connection? q : query?
If there are no results then #f is returned.
procedure
force? : boolean? = #f conn : connection? e : entity?
Raises a user error if any of the entities don’t have a primary key field.
Changed in version 0.11 of package deta-lib: Added the #:force? keyword.
procedure
(update-one! [#:force? force?] conn e) → (or/c #f entity?)
force? : boolean? = #f conn : connection? e : entity?
Equivalent to:
(match (update! conn e) [(list e) e] [_ #f])
Changed in version 0.11 of package deta-lib: Added the #:force? keyword.
procedure
conn : connection? e : entity?
Raises a user error if any of the entities don’t have a primary key field.
procedure
(delete-one! conn e) → (or/c #f entity?)
conn : connection? e : entity?
Equivalent to:
(match (delete! conn e) [(list e) e] [_ #f])
4.1.3 Query Expressions
q-expr = (and q-expr ...+) | (array q-expr ...) | (as q-expr id) | (cond [q-expr q-expr] ...+) |
(cond [q-expr q-expr] ...+ [else q-expr]) | (fragment expr) | (not q-expr) | (or q-expr ...+) | (list q-expr ...) | (subquery expr) | '(q-expr ...) | ,expr | ,@expr | ident | boolean | string | number | app app = (q-expr q-expr ...) ident = symbol
The grammar for query expressions.
Tuples are created using the (list 1 2 3) or '(1 2 3) syntax:
> (select _ (in 1 '(1 2 3 4))) (query "SELECT 1 IN (1, 2, 3, 4)")
> (select _ (in 1 (list 1 2 3 4))) (query "SELECT 1 IN (1, 2, 3, 4)")
Dynamic lists can be turned into tuples using the ,@(list 1 2 3) syntax:
> (let ([xs (list 1 2 3)]) (select _ (in 1 ,@(map add1 xs)))) (query "SELECT 1 IN (2, 3, 4)")
Note that the splicing syntax splices scalar lists directly into the query so you must be careful when using it in conjunction with untrusted user input.
Arrays are created using the (array 1 2 3) syntax:
> (select _ (array-concat (array 1 2) (array 3 4))) (query "SELECT ARRAY[1, 2] || ARRAY[3, 4]")
Various operators have built-in support and generate queries predictably. Operator names are always lower-case so (in a b) is valid, while (IN a b) is not. If you find an operator that you need doesn’t produce the query you expect, then open an issue on GitHub and I’ll fix it as soon as I can.
Within a query expression, the following identifiers are treated specially by the base (i.e. PostgreSQL) dialect. These are inherited by other dialects, but using them with those dialects may result in invalid queries.
operator
operator
operator
operator
operator
operator
operator
operator
operator
operator
> (display (select _ (array-contains? (array 1 2) (array 1)))) #<query: SELECT ARRAY[1, 2] @> ARRAY[1]>
> (display (select _ (array-overlap? (array 1) (array 1 2)))) #<query: SELECT ARRAY[1] && ARRAY[1, 2]>
> (display (select _ (array-slice (array 1 2 3 4 5) 1 3))) #<query: SELECT (ARRAY[1, 2, 3, 4, 5])[1:3]>
operator
> (display (select _ (between (date "2021-04-09") (date "2021-04-01") (date "2021-05-01")))) #<query: SELECT (DATE '2021-04-09') BETWEEN (DATE '2021-04-01') AND (DATE '2021-05-01')>
operator
> (display (select _ (bitwise-and 1 2))) #<query: SELECT 1 & 2>
operator
> (display (select _ (bitwise-not 0))) #<query: SELECT ~ 0>
operator
> (display (select _ (bitwise-xor 1 1))) #<query: SELECT 1 # 1>
operator
> (display (select _ (extract hour (timestamp "2021-04-09 18:25:00")))) #<query: SELECT EXTRACT(HOUR FROM (TIMESTAMP '2021-04-09 18:25:00'))>
operator
> (require (prefix-in ast: deta/private/ast))
> (let ([table "users"] [column "name"]) (display (~> (from ,table #:as t) (where (= (fragment (ast:qualified "t" column)) "bogdan"))))) #<query: SELECT * FROM users AS t WHERE t.name = 'bogdan'>
Changed in version 0.15 of package deta-lib: Added support for string fragments.
> (display (select _ (json-check-path (json "{}") "$.a[*] > 2"))) #<query: SELECT (JSON '{}') @@ '$.a[*] > 2'>
> (display (select _ (json-concat (json "{}") (json "{\"a\": 42}")))) #<query: SELECT (JSON '{}') || (JSON '{"a": 42}')>
> (display (select _ (json-contains-all? (json "{\"a\": 1}") (array "a" "b")))) #<query: SELECT (JSON '{"a": 1}') ?& ARRAY['a', 'b']>
> (display (select _ (json-contains-any? (json "{\"a\": 1}") (array "a" "b")))) #<query: SELECT (JSON '{"a": 1}') ?| ARRAY['a', 'b']>
> (display (select _ (json-contains-path? (json "{\"a\": {\"b\": 42}}") "$.a.b"))) #<query: SELECT (JSON '{"a": {"b": 42}}') @? '$.a.b'>
> (display (select _ (json-contains? (json "{\"a\": 42}") "a"))) #<query: SELECT (JSON '{"a": 42}') ? 'a'>
> (display (select _ (json-ref (json "{\"a\": {\"b\": 42}}") "a" "b"))) #<query: SELECT (JSON '{"a": {"b": 42}}') -> 'a' -> 'b'>
> (display (select _ (json-ref-text (json "{\"a\": \"hello\"}") "a"))) #<query: SELECT (JSON '{"a": "hello"}') ->> 'a'>
> (display (select _ (json-ref-text/path (json "{\"a\": \"hello\"}") (array "a")))) #<query: SELECT (JSON '{"a": "hello"}') #>> ARRAY['a']>
> (display (select _ (json-ref/path (json "{\"a\": \"hello\"}") (array "a")))) #<query: SELECT (JSON '{"a": "hello"}') #> ARRAY['a']>
> (display (select _ (json-remove (json "{\"a\": \"hello\"}") "a"))) #<query: SELECT (JSON '{"a": "hello"}') - 'a'>
> (display (select _ (json-remove/path (json "{\"a\": \"hello\"}") (array "a")))) #<query: SELECT (JSON '{"a": "hello"}') #- ARRAY['a']>
> (display (select _ (json-subset? (jsonb "{}") (jsonb "{\"a\": 1}")))) #<query: SELECT (JSONB '{}') <@ (JSONB '{"a": 1}')>
> (display (select _ (json-superset? (jsonb "{\"a\": 1}") (jsonb "{}")))) #<query: SELECT (JSONB '{"a": 1}') @> (JSONB '{}')>
operator
> (display (select _ (timestamp "2021-04-09 18:45:00"))) #<query: SELECT TIMESTAMP '2021-04-09 18:45:00'>
4.1.4 Query Combinators
An error is raised if q is anything other than a SELECT query.
> (delete (from "users" #:as u)) (query "DELETE FROM users AS u")
syntax
(from table-name #:as alias)
(from schema-id #:as alias) (from (subquery query) #:as alias) (from ,table-name-expr #:as alias)
table-name : non-empty-string?
query : query?
> (define-schema user ([id id/f #:primary-key #:auto-increment] [username string/f])) > > (from "users" #:as u) (query "SELECT * FROM users AS u")
> > (from user #:as u) (query "SELECT u.id, u.username FROM users AS u")
>
> (let ([tbl "users"]) (from ,tbl #:as u)) (query "SELECT * FROM users AS u")
>
> (~> (from (subquery (from user #:as u)) #:as out) (select (count out.*)))
(query
"SELECT COUNT(\"out\".*) FROM (SELECT u.id, u.username FROM users AS u) AS \"out\"")
syntax
(group-by query q-expr ...+)
The racket/list module also provides a group-by function. Importing both racket/list and deta can result in a name collision, which you can resolve using only-in, rename-in, except-in or prefix-in to selectively require the needed functions and/or rename them locally.
Adds a GROUP BY clause to query. If query already has one, then the new columns are appended to the existing clause.
> (~> (from "books" #:as b) (select b.year-published (count *)) (group-by b.year-published))
(query
"SELECT b.year_published, COUNT(*) FROM books AS b GROUP BY b.year_published")
syntax
(join query maybe-type maybe-lateral table-name #:as alias maybe-condition)
(join query maybe-type maybe-lateral schema-id #:as alias maybe-condition) (join query maybe-type maybe-lateral (subquery query) #:as alias maybe-condition) (join query maybe-type maybe-lateral ,table-name-expr #:as alias maybe-condition)
maybe-type =
| #:inner | #:left | #:right | #:full | #:cross maybe-lateral =
| #:lateral maybe-condition =
| #:on q-expr
table-name : non-empty-string?
query : query?
> (~> (from "posts" #:as p) (join "post_images" #:as pi #:on (= p.id pi.post-id)) (join #:left "comments" #:as c #:on (= p.id c.post-id)) (select p.* c.*))
(query
"SELECT p.*, c.* FROM posts AS p JOIN post_images AS pi ON p.id = pi.post_id LEFT JOIN comments AS c ON p.id = c.post_id")
The first form raises a syntax error if n is not an exact positive integer or 0.
> (~> (from "users" #:as u) (offset 20) (limit 10)) (query "SELECT * FROM users AS u LIMIT 10 OFFSET 20")
The first form raises a syntax error if n is not an exact positive integer or 0.
> (~> (from "users" #:as u) (offset 10)) (query "SELECT * FROM users AS u OFFSET 10")
syntax
(order-by query ([column maybe-direction] ...+))
maybe-direction =
| #:asc maybe-nulls-direction | #:desc maybe-nulls-direction | ,direction-expr maybe-nulls-direction maybe-nulls-direction =
| #:nulls-first | #:nulls-last | ,nulls-direction-expr
direction-expr : (or/c 'asc 'desc)
nulls-direction-expr : (or/c 'nulls-first 'nulls-last)
> (~> (from "users" #:as u) (order-by ([u.last-login #:desc] [u.username]))) (query "SELECT * FROM users AS u ORDER BY u.last_login DESC, u.username")
> (define direction 'desc)
> (~> (from "users" #:as u) (order-by ([u.last-login ,direction]))) (query "SELECT * FROM users AS u ORDER BY u.last_login DESC")
> (~> (from "artworks" #:as a) (order-by ([a.year #:desc #:nulls-last]))) (query "SELECT * FROM artworks AS a ORDER BY a.\"year\" DESC NULLS LAST")
procedure
(project-onto q s) → query?
q : query? s : schema?
> (define-schema book-stats #:virtual ([year-published integer/f] [books integer/f]))
> (~> (from "books" #:as b) (select b.year-published (count *)) (group-by b.year-published) (order-by ([b.year-published #:desc])) (project-onto book-stats-schema))
(query
"SELECT b.year_published, COUNT(*) FROM books AS b GROUP BY b.year_published ORDER BY b.year_published DESC")
Omitting the select does not produce the expected query in this case since the books table has different fields from a book-stats value:
>
> (~> (from "books" #:as b) (group-by b.year-published) (order-by ([b.year-published #:desc])) (project-onto book-stats-schema))
(query
"SELECT * FROM books AS b GROUP BY b.year_published ORDER BY b.year_published DESC")
procedure
(project-virtual-fields q) → query?
q : query?
syntax
(returning query q-expr ...+)
> (~> (delete (from "users" #:as u)) (where (not u.active?)) (returning u.id)) (query "DELETE FROM users AS u WHERE NOT u.is_active RETURNING u.id")
The first form (with the _) generates a fresh query.
> (select _ 1 2) (query "SELECT 1, 2")
> (select (from "users" #:as u) u.username) (query "SELECT u.username FROM users AS u")
syntax
(select-for-schema query schema #:from tbl-alias-id)
(select-for-schema query schema #:from tbl-alias-id #:customizing ([field-id q-expr] ...))
schema = schema-id | ,schema-expr
query : query?
schema-expr : schema?
> (define-schema example ([a string/f] [b integer/f] [c integer/f])) >
> (~> (from example #:as e) (select-for-schema example #:from e)) (query "SELECT e.a, e.b, e.c FROM examples AS e")
>
> (~> (from example #:as e) (join "some_table" #:as t #:on (= t.a e.a)) (select-for-schema example #:from e #:customizing ([c (* t.c 2)])))
(query
"SELECT e.a, e.b, t.c * 2 FROM examples AS e JOIN some_table AS t ON t.a = e.a")
Use this operator in conjunction with project-virtual-fields to project joined or otherwise-constructed virtual fields onto a schema.
syntax
(union query-1 query-2)
> (~> (select _ 1) (union (select _ 2)) (union (select _ 3))) (query "SELECT 1 UNION (SELECT 2 UNION (SELECT 3))")
syntax
(update query assignment ...+)
assignment = [column-id q-expr]
An error is raised if q is anything other than a SELECT query.
> (~> (from "users" #:as u) (update [active? #t])) (query "UPDATE users AS u SET is_active = TRUE")
syntax
(where query q-expr)
> (~> (from "users" #:as u) (where u.active?) (where (> u.last-login (- (now) (interval "2 weeks")))))
(query
"SELECT * FROM users AS u WHERE u.is_active AND (u.last_login > ((NOW()) - (INTERVAL '2 weeks')))")
syntax
(or-where query q-expr)
> (~> (delete (from "users" #:as u)) (where (not u.active?)) (or-where (< u.last-login (- (now) (interval "1 year")))))
(query
"DELETE FROM users AS u WHERE (NOT u.is_active) OR (u.last_login < ((NOW()) - (INTERVAL '1 year')))")
4.2 Schema
procedure
(make-entity conn-or-dialect schema cols) → entity?
conn-or-dialect : (or/c connection? symbol?) schema : schema? cols : (listof any/c)
Two entites are equal? when they are instances of the same schema and all their fields are equal?.
procedure
(entity-schema e) → schema?
e : entity?
procedure
(entity->hash e [f]) → (hash/c symbol? any/c)
e : entity? f : (-> symbol? any/c any/c) = (λ (k v) v)
The f argument can be used to convert individual fields’ values before they are added to the hash.
> (require gregor)
> (define-schema book #:virtual ([title string/f] [author string/f] [published-at datetime-tz/f]))
> (define b (make-book #:title "Lord of the Rings" #:author "J. R. R. Tolkien" #:published-at (iso8601->moment "1954-07-29T00:00:00Z"))) > (entity->hash b)
'#hasheq((author . "J. R. R. Tolkien")
(published-at . #<moment 1954-07-29T00:00:00Z>)
(title . "Lord of the Rings"))
> (entity->hash b (λ (_ v) (cond [(moment? v) (moment->iso8601 v)] [else v])))
'#hasheq((author . "J. R. R. Tolkien")
(published-at . "1954-07-29T00:00:00Z")
(title . "Lord of the Rings"))
syntax
(define-schema id maybe-table maybe-virtual (field-definition ...+) maybe-pre-persist-hook maybe-pre-delete-hook maybe-struct-option ...)
field-definition =
[id field-type maybe-name maybe-primary-key maybe-auto-increment maybe-unique maybe-nullable maybe-contract maybe-wrapper] |
[(id default-expr) field-type maybe-name maybe-primary-key maybe-auto-increment maybe-unique maybe-nullable maybe-virtual maybe-contract maybe-wrapper] maybe-table =
| #:table table-name maybe-virtual =
| #:virtual maybe-name =
| #:name field-name maybe-primary-key =
| #:primary-key maybe-auto-increment =
| #:auto-increment maybe-unique =
| #:unique maybe-nullable =
| #:nullable maybe-contract =
| #:contract e maybe-wrapper =
| #:wrapper e maybe-pre-persist-hook =
| #:pre-persist-hook pre-persist-hook maybe-pre-delete-hook =
| #:pre-delete-hook pre-delete-hook maybe-struct-option =
| struct-option
table-name : non-empty-string?
field-name : non-empty-string?
field-type : type?
pre-persist-hook : (-> entity? entity?)
pre-delete-hook : (-> entity? entity?)
For every defined field there will be an associated functional setter and updater named set-id-field and update-id-field, respectively.
Fields with a default-expr are optional with respect to the smart constructor. When such a field is elided from a call to the constructor, its value will be the result of evaluating its default-expr at the time the struct is instantiated. Field default expressions are a Racket-only construct and have no effect on DDL statements.
If a table-name is provided, then that is used as the name for the table. Otherwise, an “s” is appended to the schema id to pluralize it. Currently, there are no other pluralization rules.
If #:virtual is provided on a schema definition, then the resulting schema’s entities will not be able to be persisted, nor will the schema be registered in the global registry.
Alternatively, individual fields may be set as #:virtual. Such fields must have a default value defined, and will be ignored when performing database operations. in-entities will produce entities with virtual fields set to their default values. Virtual fields may be used for data not directly mapped to database columns (see also the any/f field type.)
The pre-persist-hook is run before an entity is either insert!ed or update!d.
The pre-delete-hook is run before an entity is delete!d.
Hooks do not run for arbitrary queries.
All provided struct-options are passed directly to the underlying struct definition.
A syntax error is raised if you declare a field as both a primary key and nullable. Additionally, a syntax error is raised if a schema has multiple primary keys.
Every type has an associated contract so the #:contract option for fields is only necessary if you want to further restrict the values that a field can contain.
The #:wrapper option allows you to supply a function for use in normalizing values. The function will be automatically called when using a schema’s make- or setter functions, and its return value is the value that will be stored in the database. A wrapper function cannot be used to coerce values to the field’s type; both the value passed to it and the value it returns must satisfy the field’s contract.
When converting field names to SQL, dashes are replaced with underscores and field names that end in question marks drop their question mark and are prefixed with is_, so that, for example, admin? becomes is_admin.
Custom field names can be specified by providing a #:name in the field definition. Note, however, that the library does not currently translate between field names and custom column names within arbitrary queries.
Example:
> (define-schema book ([id id/f #:primary-key #:auto-increment] [title string/f #:unique #:contract non-empty-string? #:wrapper string-titlecase] [author string/f #:contract non-empty-string?] [(created-at (now/moment)) datetime-tz/f] [(updated-at (now/moment)) datetime-tz/f]) #:pre-persist-hook (lambda (b) (set-book-updated-at b (now/moment))))
> (define b (make-book #:title "a book" #:author "An Author")) > (book-title b) "A Book"
> (book-author b) "An Author"
> (book-created-at b) #<moment 2024-09-17T14:54:25.061834473Z[Etc/UTC]>
syntax
(schema-out schema)
> (module sub racket/base (require deta) (provide (schema-out album)) (define-schema album #:virtual ([id id/f #:primary-key #:auto-increment] [title string/f] [band string/f]))) > > (require 'sub)
> (define an-album (make-album #:title "Led Zeppelin" #:band "Led Zeppelin")) > > (album? an-album) #t
> (album-title an-album) "Led Zeppelin"
> (album-title (update-album-title an-album string-upcase)) "LED ZEPPELIN"
4.3 Type
These are all the field types currently supported by deta. Note that not all database backends support all of these types.
4.3.1 Support Matrix
Here are all the types and how they map to the different backends.
Field Type |
| Racket Type |
| PostgreSQL Type |
| SQLite Type |
|
| INTEGER / SERIAL |
| INTEGER | ||
|
| INTEGER |
| INTEGER | ||
|
| REAL |
| REAL | ||
|
| NUMERIC |
| UNSUPPORTED | ||
|
| TEXT |
| TEXT | ||
|
| BYTEA |
| BLOB | ||
|
| TEXT |
| TEXT | ||
|
| BOOLEAN |
| INTEGER | ||
|
| DATE |
| TEXT | ||
|
| TIME |
| TEXT | ||
|
| TIMESTAMP |
| TEXT | ||
|
| TIMESTAMPTZ |
| TEXT | ||
|
| UUID |
| UNSUPPORTED | ||
|
| POINT |
| UNSUPPORTED | ||
|
| ARRAY |
| UNSUPPORTED | ||
|
| JSON |
| TEXT | ||
|
| JSONB |
| UNSUPPORTED | ||
|
| N/A |
| N/A |
4.3.2 Types
procedure
v : any/c
value
value
value
procedure
precision : exact-integer? scale : exact-integer?
value
value
value
value
value
value
value
value
value
value
procedure
t : type?
value
value
4.3.3 Custom Types
(require deta/type) | package: deta-lib |
Custom types may be defined using the define-type form.
syntax
(define-type id maybe-fields maybe-option ...)
maybe-fields =
| (field-id ...) maybe-option =
| contract-decl | #:declaration declaration-expr | #:constructor constructor-expr | #:dump dump-expr | #:load load-expr contract-decl = #:contract contract-expr | #:contract-fn contract-fn-expr
contract-expr : contract?
contract-fn-expr : (-> any/c contract?)
declaration-expr : (or/c string? (-> any/c symbol? string?))
The #:declaration option controls how the type is rendered in schema declarations. This is the only required option. The associated expression may either a string? or a function that takes as input the field type and the current dialect.
The #:contract option controls the default contract for values of the data type. The #:contract-fn option is mutually-exclusive with the #:contract option and it can be used to parameterize the contract over the type.
The #:constructor option lets you declare a custom constructor procedure for instances of the data type. This is useful when declaring parameterized types (such as array/f or numeric/f).
The #:dump option can be used to manipulate values before they are passed to the database. Its associated expression must be a procedure of three arguments: the data type, the current dialect and the value to dump.
The #:load option can be used to manipulate values before they are passed into entities. It is the dual of #:dump.
> (module example racket/base (require deta/type racket/contract) (define-type bigint #:contract exact-integer? #:declaration "BIGINT") (define-type bigint-array #:contract (vectorof exact-integer?) #:declaration (lambda (t dialect) (case dialect [(postgresql) "BIGINT[]"] [else (raise-argument-error 'bigint-array "'postgresql" dialect)])))) > (require 'example) > (bigint/f? bigint/f) #t
> (bigint-array/f? bigint-array/f) #t
See "deta-lib/type.rkt" for more examples.
4.4 Reflection
(require deta/reflect) | package: deta-lib |
The bindings provided by this module let you access schema and field metadata at runtime. Before 1.0 is released, I might make breaking changes to this module. It’s unlikely, but it could happen so keep that in mind!
parameter
(current-schema-registry registry) → void? registry : (hash/c symbol? schema?)
= (make-hasheq)
parameter
(schema-registry-allow-conflicts? allow?) → void? allow? : boolean?
= #f
procedure
(schema-registry-lookup id) → schema?
id : symbol?
procedure
(schema-virtual? s) → boolean?
s : schema?
procedure
(schema-fields s) → (listof field?)
s : schema?
procedure
(schema-table s) → string?
s : schema?
procedure
(field-name f) → string?
f : field?
procedure
(field-type f) → type?
f : field?
procedure
(type-declaration t dialect) → string?
t : type? dialect : (or/c 'postgresql 'sqlite3)