{"id":27873085,"url":"https://github.com/retro/penkala","last_synced_at":"2025-05-05T01:06:30.280Z","repository":{"id":38001056,"uuid":"306018152","full_name":"retro/penkala","owner":"retro","description":"Composable query builder for PostgreSQL written in Clojure.","archived":false,"fork":false,"pushed_at":"2022-09-01T19:43:14.000Z","size":1924,"stargazers_count":102,"open_issues_count":6,"forks_count":7,"subscribers_count":10,"default_branch":"master","last_synced_at":"2025-05-05T01:06:19.086Z","etag":null,"topics":["clojure","postgresql"],"latest_commit_sha":null,"homepage":"","language":"PLpgSQL","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/retro.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-10-21T12:31:38.000Z","updated_at":"2025-01-16T15:03:08.000Z","dependencies_parsed_at":"2022-09-08T07:25:46.855Z","dependency_job_id":null,"html_url":"https://github.com/retro/penkala","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retro%2Fpenkala","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retro%2Fpenkala/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retro%2Fpenkala/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retro%2Fpenkala/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/retro","download_url":"https://codeload.github.com/retro/penkala/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":252420966,"owners_count":21745154,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["clojure","postgresql"],"created_at":"2025-05-05T01:06:29.665Z","updated_at":"2025-05-05T01:06:30.271Z","avatar_url":"https://github.com/retro.png","language":"PLpgSQL","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Penkala\n\n[![Clojars Project](https://img.shields.io/clojars/v/com.verybigthings/penkala.svg)](https://clojars.org/com.verybigthings/penkala)\n\nPenkala is a composable query builder for PostgreSQL written in Clojure.\n\n## Motivation\n\nClojure has a number of libraries to interact with the database layer. So, why write another one? For my needs, I find them\nall operating on a wrong level of abstraction. Libraries like [HugSQL](https://www.hugsql.org/) and [HoneySQL](https://github.com/seancorfield/honeysql) allow you to use full extent of SQL, but \nstill require you to write a lot of boilerplate, while libraries like ~~[Walkable](https://github.com/walkable-server/walkable) and~~ [seql](https://github.com/exoscale/seql) operate on a very high level, but are \neschewing SQL and instead use EQL to write the queries.\n\n_In the previous version of the readme, Walkable was mentioned as one of the libraries that operate on EQL level vs. SQL level. Walkable does have an EQL layer, but it also offers a lower level API that allows you to express SQL in a more direct manner (https://walkable.gitlab.io/walkable/1.3.0/s-expressions.html)._\n\nI wanted something in the middle:\n\n- Support for SQL semantics\n- High(er) level API than HugSQL or HoneySQL have\n- Implementation that is not \"taking over\" - you should be able to use Penkala in combination with any other library if you need to\n- Composability\n- API that is as pure as possible\n- First class support for PostgreSQL features\n\nThe idea is to get as close to the features offered by ORM libraries in other languages, without taking on all the complexity\nthat ORMs bring with them.\n\n## Relation as a first class citizen\n\nThe API is inspired by many libraries (Ecto, Massive.js, rasql), but the main inspiration comes from a library called bmg - written in Ruby. You can read more about its design decisions\n [here](http://www.try-alf.org/blog/2013-10-21-relations-as-first-class-citizen). While Penkala is _not_ implementing \n relational algebra, and is instead staying close to the SQL semantics, the API is designed around the same principles - composition\n and reuse are built in.\n \nEvery operation on a relation is returning a _new_ relation that you can use and reuse in other queries.\n \n## Usage\n\n```clojure\n(require '[com.verybigthings.penkala.relation :as r])\n\n(def users-spec {:name \"users\"\n                 :columns [\"id\" \"username\" \"is_admin\"]\n                 :pk [\"id\"]\n                 :schema \"public\"})\n\n(def users-rel (r/spec-\u003erelation users-spec))\n\n(r/get-select-query users-rel {})\n\n=\u003e [\"SELECT \\\"users\\\".\\\"id\\\" AS \\\"id\\\", \\\"users\\\".\\\"is_admin\\\" AS \\\"is-admin\\\", \\\"users\\\".\\\"username\\\" AS \\\"username\\\" FROM \\\"public\\\".\\\"users\\\" AS \\\"users\\\"\"]\n```\n\nTo create a relation, you need a relation spec. This describes the table (or view) columns, its name, primary key and schema.\nThe spec can be used to create a relation, which can then be used to generate queries.\n\n`r/get-select-query` function returns a \"sqlvec\" where the first element in the vector is the SQL query, and the remaining \nelements are the query parameters.\n\n```clojure\n(def admins-rel (r/where users-rel [:is-true :is-admin]))\n(r/get-select-query admins-rel {})\n\n=\u003e [\"SELECT \\\"users\\\".\\\"id\\\" AS \\\"id\\\", \\\"users\\\".\\\"is_admin\\\" AS \\\"is-admin\\\", \\\"users\\\".\\\"username\\\" AS \\\"username\\\" FROM \\\"public\\\".\\\"users\\\" AS \\\"users\\\" WHERE \\\"users\\\".\\\"is_admin\\\" IS TRUE\"]\n```\n\nIn this case, we've created a new relation - `admins-rel` which is representing only the admin users.\n\nLet's introduce a new relation - `posts` and load posts only written by admins\n\n```clojure\n(def posts-spec {:name \"posts\"\n                 :columns [\"id\" \"user_id\" \"body\"]\n                 :pk [\"id\"]\n                 :schema \"public\"})\n  \n(def posts-rel (r/spec-\u003erelation users-spec))\n\n(r/get-select-query (r/where posts-rel [:in :user-id (r/select admins-rel [:id])]) {})\n=\u003e [\"SELECT \\\"posts\\\".\\\"body\\\" AS \\\"body\\\", \\\"posts\\\".\\\"id\\\" AS \\\"id\\\", \\\"posts\\\".\\\"user_id\\\" AS \\\"user-id\\\" FROM \\\"public\\\".\\\"posts\\\" AS \\\"posts\\\" WHERE \\\"posts\\\".\\\"user_id\\\" IN (SELECT \\\"sq_7758__users\\\".\\\"id\\\" AS \\\"id\\\" FROM \\\"public\\\".\\\"users\\\" AS \\\"sq_7758__users\\\" WHERE \\\"sq_7758__users\\\".\\\"is_admin\\\" IS TRUE)\"]\n```\n\nOr maybe, we want only posts posted by admins joined with their author:\n\n```clojure\n(r/get-select-query (r/join posts-rel :left admins-rel :author [:= :user-id :author/id]) {})\n=\u003e [\"SELECT \\\"posts\\\".\\\"body\\\" AS \\\"body\\\", \\\"posts\\\".\\\"id\\\" AS \\\"id\\\", \\\"posts\\\".\\\"user_id\\\" AS \\\"user-id\\\", \\\"author\\\".\\\"id\\\" AS \\\"author__id\\\", \\\"author\\\".\\\"is-admin\\\" AS \\\"author__is-admin\\\", \\\"author\\\".\\\"username\\\" AS \\\"author__username\\\" FROM \\\"public\\\".\\\"posts\\\" AS \\\"posts\\\" LEFT OUTER JOIN (SELECT \\\"users\\\".\\\"id\\\" AS \\\"id\\\", \\\"users\\\".\\\"is_admin\\\" AS \\\"is-admin\\\", \\\"users\\\".\\\"username\\\" AS \\\"username\\\" FROM \\\"public\\\".\\\"users\\\" AS \\\"users\\\" WHERE \\\"users\\\".\\\"is_admin\\\" IS TRUE) \\\"author\\\" ON \\\"posts\\\".\\\"user_id\\\" = \\\"author\\\".\\\"id\\\"\"]\n```\n\n_Notice that you're using namespaced keywords when you're referencing columns in joined relations, where the namespace is the\nalias used to join the relation._\n\nAs you can see, Penkala takes care of all the nitty-gritty details around sub-selects, joins and aliasing. Relation API\nfunctions are implemented, documented and spec'ed in the `com.verybigthings.penkala.relation` namespace.\n\nPenkala provides the integration with the [next.jdbc](https://github.com/seancorfield/next-jdbc/) library, which will query the DB, get the information about the\ntables and views and return a map with relations based on this information.\n\n```clojure\n(require '[com.verybigthings.penkala.next-jdbc :refer [get-env]])\n(get-env \"db-url-or-spec\")\n```\n\n\n### Value expressions\n\nA lot of the relation API functions accept value expressions. Value expressions are used to write where clauses, extend \nrelations with computed columns, write having clauses, etc.. Value expressions API is heavily inspired by the HoneySQ library,\nand value expressions are written as vectors:\n\n```clojure\n[:= :id 1]\n```\n\nIn this case Penkala will look for a column named `:id` and compare it to the value `1`. Value expressions can be arbitrarily\nnested to any depth, and allow you to write almost any SQL you might need.\n\nSome examples:\n\n```clojure\n;; Complex where predicate\n(-\u003e *env* :products (r/where [:and [:= :id 1] [:= :price 12.0] [:is-null :tags]]))\n\n;; Querying a JSON field\n(-\u003e *env* :products (r/where [:= [\"#\u003e\u003e\" :specs [\"weight\"]] [:cast 30 \"text\"]]))\n\n;; Use a POSIX regex in a where clause\n(-\u003e *env* :products (r/where [\"!~*\" :name \"product[ ]*[2-4](?!otherstuff)\"]))\n\n;; Use the \"overlap\" operator\n(-\u003e *env* :products (r/where [\"\u0026\u0026\" :tags [\"tag3\" \"tag4\" \"tag5\"]]))\n```\n\nWhen writing a value expression, first element of a vector will be used to determine the type of the expression (operator, function, etc.) and the\nrest will be sent as arguments. Keywords have a special meaning in value expressions, and when encountering them (anywhere except in the first position), \nPenkala will check if there is a column with that name in the current relation. If there is, it will treat it as a column name, and if it is not, it will\ntreat it as a value. If you need to explicitly treat something as a value or param or column... `com.verybigthings.penkala.helpers` provides wrapper \nfunctions which will allow you to explicitly mark something as a value or as a column.\n\nOne of the cases where you'll need to explicitly wrap keywords is when you want to provide named params to the query. So, far all examples\ninlined the params in the value expression, but Penkala provides a better way, which will allow you to write reusable value expressions:\n\n```clojure\n(require '[com.verybigthings.penkala.helpers :refer [param]])\n(r/get-select-query (r/where posts-rel [:= :user-id (param :user/id )]) {} {:user/id 1})\n=\u003e [\"SELECT \\\"posts\\\".\\\"body\\\" AS \\\"body\\\", \\\"posts\\\".\\\"id\\\" AS \\\"id\\\", \\\"posts\\\".\\\"user_id\\\" AS \\\"user-id\\\" FROM \\\"public\\\".\\\"posts\\\" AS \\\"posts\\\" WHERE \\\"posts\\\".\\\"user_id\\\" = ?\" 1]\n```\n\n### Decomposition\n\nIf the first half of boilerplate you have to write when you generate SQL queries is composition, then the second one is _decomposition_\nof results into a graph form (nested maps and vectors). Penkala provides a decomposition layer which allows you to get the results\nin the format that's usable. Penkala can infer the decomposition schema from the relation, so in most cases you won't have to do\nanything to get it working. In other cases, you can override the behavior. This allows you to query the DB with a convenience of an ORM\nwithout having to use limited concepts like has many, has one, many to many, you get the same results while being able to use the rest of\nthe Penkala API.\n\nHere's an example from the test suite:\n\n```clojure\n(ns com.verybigthings.penkala.relation.join-test\n  (:require [clojure.test :refer :all]\n            [com.verybigthings.penkala.next-jdbc :refer [select!]]\n            [com.verybigthings.penkala.relation :as r]\n            [com.verybigthings.penkala.test-helpers :as th :refer [*env*]]\n            [com.verybigthings.penkala.decomposition :refer [map-\u003eDecompositionSchema]]))\n\n(deftest it-joins-multiple-tables-at-multiple-levels\n  (let [alpha (:alpha *env*)\n        beta (:beta *env*)\n        gamma (:gamma *env*)\n        sch-delta (:sch/delta *env*)\n        sch-epsilon (:sch/epsilon *env*)\n        joined (-\u003e alpha\n                 (r/join :inner\n                   (-\u003e beta\n                     (r/join :inner gamma :gamma [:= :id :gamma/beta-id])\n                     (r/join :inner sch-delta :delta [:= :id :delta/beta-id]))\n                   :beta\n                   [:= :id :beta/alpha-id])\n                 (r/join :inner sch-epsilon :epsilon [:= :id :epsilon/alpha-id]))\n        res (select! *env* joined)]\n    (is (= [#:alpha{:val \"one\"\n                    :id 1\n                    :beta\n                    [#:beta{:val \"alpha one\"\n                            :j nil\n                            :id 1\n                            :alpha-id 1\n                            :gamma\n                            [#:gamma{:beta-id 1\n                                     :alpha-id-one 1\n                                     :alpha-id-two 1\n                                     :val \"alpha one alpha one beta one\"\n                                     :j nil\n                                     :id 1}]\n                            :delta\n                            [#:delta{:beta-id 1 :val \"beta one\" :id 1}]}],\n                    :epsilon [#:epsilon{:val \"alpha one\" :id 1 :alpha-id 1}]}]\n          res))))\n```\n\nIn this case, multiple relations were joined (on multiple levels) and then transformed into a graph form. Decomposition schema\nwas inferred from the relation joins structure.\n\n## Insert, Update and Delete\n\nFrom version 0.0.3 Penkala supports insert, update and delete statements. To insert, update or delete data you need to create an \"Insertable\", \"Updatable\" or \"Deletable\" (commonly called \"Writeable\") record from the base relation. If you create a writeable record from a relation that has joins, it will use only the topmost relation. Insertable and updatable will filter the data passed to them and only use the keys that relate to the columns in the target table.\n\n### Insert\n\nIf we have a `posts` relation:\n\n```clojure\n(def posts-spec {:name \"posts\"\n                 :columns [\"id\" \"user_id\" \"body\"]\n                 :pk [\"id\"]\n                 :schema \"public\"})\n  \n(def posts-rel (r/spec-\u003erelation posts-spec))\n```\n\nWe can create an insertable record:\n\n```clojure\n(def posts-ins (r/-\u003einsertable posts-rel))\n```\n\nThis insertable record will by default use the `returning` statement, and will return all columns from the created record(s). You can change the returned columns by using the `returning` function\n\n```clojure\n(r/returning posts-ins nil)\n```\n\nIn this case, default `next.jdbc` data will be returned which will include the count of the updated rows:\n\n```clojure\n#:next.jdbc{:update-count 1}\n```\n\n```clojure\n(r/returning posts-ins [:id])\n```\n\nIn this case, only the `id` column will be returned.\n\nTo actually insert the data, use `com.verybigthings.penkala.next-jdbc/insert!` function:\n\n```clojure\n(insert! *env* posts-ins {:user-id 1 :body \"This is my first post\"})\n=\u003e #:posts{:id 1\n           :user-id 1\n           :body \"This is my first post\"}\n```\n\nIf you pass a vector of maps to the `insert!` function, multiple records will be created, and a vector of results will be returned\n\n```clojure\n(insert! *env* posts-ins [{:user-id 1 :body \"This is my first post\"} {:user-id 1 :body \"This is my second post\"}])\n=\u003e [#:posts{:id 1\n           :user-id 1\n           :body \"This is my first post\"}\n    #:posts{:id 2\n            :user-id 1\n            :body \"This is my second post\"}]\n```\n\n#### Upsert\n\nPenkala supports upserts (\"INSERT ... ON CONFLICT\"). There are two functions - `on-conflict-do-nothing` and `on-conflict-do-update` - exposed:\n\n```clojure\n(insert! *env* \n (-\u003e posts-ins\n  (r/on-conflict-do-nothing))\n {:user-id 1 :body \"This is my first post\"})\n=\u003e nil\n```\n\nYou can pass explicit \"conflict target\" to the `on-conflict-do-...` functions:\n\n```clojure\n(insert! *env* \n (-\u003e posts-ins\n  (r/on-conflict-do-nothing [:body]))\n {:user-id 1 :body \"This is my first post\"})\n=\u003e nil\n```\n\nor\n\n```clojure\n(insert! *env* \n (-\u003e posts-ins\n  (r/on-conflict-do-nothing [:on-constraint \"posts_pkey\"]))\n {:id 1 :user-id 1 :body \"This is my first post\"})\n=\u003e nil\n```\n\nIf you use constraint inference (and you're not passing explicit constraint name through the `:on-constraint` form), you can add the \"WHERE\" clause to the `on-conflict-do-...` functions:\n\n```clojure\n(insert! *env* \n (-\u003e posts-ins\n  (r/on-conflict-do-nothing [:body] [:= :id 1]))\n {:user-id 1 :body \"This is my first post\"})\n=\u003e nil\n```\n\nWhen using `on-conflict-do-update` function, you must pass the update map:\n\n```clojure\n(insert! *env*\n  (-\u003e posts-ins\n    (r/on-conflict-do-update\n      [:body]\n      {:body [:concat :excluded/body \" (1)\"]}))\n  {:user-id 1\n   :body \"This is my first post\"})\n=\u003e #:posts{:id 1\n           :user-id 1\n           :body \"This is my first post (1)\"} \n```\n\nIn the update map, keys are names of the columns and values are \"value expressions\". When using `on-conflict-do-update` function, you get access to the implicit \"EXCLUDED\" table (https://www.postgresql.org/docs/12/sql-insert.html).\n\n## Update\n\nIf we have a `posts` relation:\n\n```clojure\n(def posts-spec {:name \"posts\"\n                 :columns [\"id\" \"user_id\" \"body\"]\n                 :pk [\"id\"]\n                 :schema \"public\"})\n  \n(def posts-rel (r/spec-\u003erelation users-spec))\n```\n\nWe can create an updatable record:\n\n```clojure\n(def posts-upd (r/-\u003eupdatable posts-rel))\n```\n\nNow we can perform updates by using the `com.verybigthings.penkala.next-jdbc/update!` function:\n\n```clojure\n(update! *env*\n (-\u003e posts-upd\n  (r/where [:= :id 1]))\n {:body \"This is my updated title\"})\n=\u003e [#:posts{:id 1\n            :user-id 1\n            :body \"This is my updated title\"}]\n```\n\nWhere function in updatables supports value expressions (like selects).\n\nUpdatables implement the `returning` function which can be used to select the returned columns (like inserts):\n\n```clojure\n(update! *env*\n (-\u003e posts-upd\n  (r/where [:= :id 1])\n  (r/returning nil))\n {:body \"This is my updated title\"})\n=\u003e #:next.jdbc{:update-count 1}\n```\n\nPenkala supports the `FROM` clause in updates, which allows you to join tables and reference them from the `WHERE` clause or from the update value expressions:\n\n_Example from the tests:_\n\n```clojure\n(deftest it-updates-with-from-tables\n  (let [normal-pk (:normal-pk *env*)\n        normal-pk-id-1 (select-one! *env* (-\u003e normal-pk\n                                            (r/where [:= :id 1])))\n        upd-normal-pk (-\u003e (r/-\u003eupdatable normal-pk)\n                        (r/from normal-pk :normal-pk-2)\n                        (r/from normal-pk :normal-pk-3)\n                        (r/where [:and [:= :id 1]\n                                  [:= :id :normal-pk-2/id]\n                                  [:= :id :normal-pk-3/id]]))\n        res (update! *env* upd-normal-pk {:field-1 [:concat \"from-outside\" \"\u003c-\u003e\" :normal-pk-2/field-1 \"\u003c-\u003e\" :normal-pk-3/field-1]\n                                          :field-2 \"foo\"})]\n    (is (= [#:normal-pk{:field-1 (str \"from-outside\u003c-\u003e\" (:normal-pk/field-1 normal-pk-id-1) \"\u003c-\u003e\" (:normal-pk/field-1 normal-pk-id-1))\n                        :json-field nil\n                        :field-2 \"foo\"\n                        :array-of-json nil\n                        :array-field nil\n                        :id 1}]\n          res))))\n```\n\nIn this example `normal-pk` table is self joined twice, under `:normal-pk-2` and `:normal-pk-3` aliases and then referenced both in the `WHERE` clause\nand in the update map.\n\nIn update map, keys are names of columns, and values are value expressions that update the column. Only the keys that match column names will be selected from the update map.\n\n### Delete\n\nIf we have a `posts` relation:\n\n```clojure\n(def posts-spec {:name \"posts\"\n                 :columns [\"id\" \"user_id\" \"body\"]\n                 :pk [\"id\"]\n                 :schema \"public\"})\n  \n(def posts-rel (r/spec-\u003erelation users-spec))\n```\n\nWe can create a deletable record:\n\n```clojure\n(def posts-del (r/-\u003edeletable posts-rel))\n```\n\nNow we can perform deletes by using the `com.verybigthings.penkala.next-jdbc/delete!` function:\n\n```clojure\n(delete! *env*\n (-\u003e posts-del\n  (r/where [:= :id 1])))\n=\u003e [#:posts{:id 1\n            :user-id 1\n            :body \"This is my updated title\"}]\n```\n\nWhere function in deletables supports value expressions (like selects).\n\nDeletables implement the `returning` function which can be used to select the returned columns (like inserts):\n\n```clojure\n(delete! *env*\n (-\u003e posts-upd\n  (r/where [:= :id 1])\n  (r/returning nil)))\n=\u003e #:next.jdbc{:update-count 1}\n```\n\nPenkala supports the `USING` clause in deletes, which allows you to join tables and reference them from the `WHERE` clause:\n\n_Example from the tests:_\n\n```clojure\n(deftest it-deletes-with-using-multiple\n  (let [products (:products *env*)\n        del-products (-\u003e (r/-\u003edeletable products)\n                       (r/using (r/where products [:= :id 3]) :other-products-1)\n                       (r/using (r/where products [:= :id 3]) :other-products-2)\n                       (r/where [:and\n                                 [:= :id :other-products-1/id]\n                                 [:= :id :other-products-2/id]]))\n        res (delete! *env* del-products)]\n    (is (= [#:products{:description nil,\n                       :tags nil,\n                       :string \"three\",\n                       :id 3,\n                       :specs nil,\n                       :case-name nil,\n                       :price 0.00M}]\n          (mapv #(dissoc % :products/uuid) res)))))\n```\n\nIn this example `products` table is joined twice under `:other-products-1` and `:other-products-2` alias and then referenced in the `WHERE` clause.\n\n## Credits\n\nI've spent a lot of time researching other libraries that are doing the similar thing, but two of them affected Penkala the most\n\n- [Massive.js](https://massivejs.org/) - Penkala started as a port of this lib, and I'm thankful I was able to study its source code to learn how to approach the architecture. Also, the tests and SQL queries to get the DB structure are copied from the Massive.js codebase.\n- [bmg](https://github.com/enspirit/bmg) - this library helped me to design the API in a way that's composable and reusable\n\nNext.jdbc integration code is taken from Luminus, so I want to thank the Lumius team for that effort.\n\nOther libraries I've researched:\n\n- https://github.com/cdinger/rasql\n- https://github.com/active-group/sqlosure\n- https://clojureql.sabrecms.com/en/welcome\n- https://github.com/tatut/specql\n\nPenkala development is kindly sponsored by [Very Big Things](https://verybigthings.com)\n\n## License\n\nCopyright © 2020 Mihael Konjevic (https://verybigthings.com)\n\nDistributed under the MIT License.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fretro%2Fpenkala","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fretro%2Fpenkala","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fretro%2Fpenkala/lists"}