datomic-flare 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ ## Flare DSL
2
+
3
+ It provides a Ruby-familiar approach to working with Datomic. It brings Ruby’s conventions and idioms while preserving Datomic’s data-first principles and terminology.
4
+
5
+ This approach should be cozy to those who are familiar with Ruby.
6
+
7
+ Learn more about Ruby and The Rails Doctrine:
8
+
9
+ - [About Ruby](https://www.ruby-lang.org/en/about/)
10
+ - [The Rails Doctrine](https://rubyonrails.org/doctrine)
11
+
12
+ ### Creating a Database
13
+
14
+ ```ruby:runnable
15
+ client.dsl.create_database!('radioactive')
16
+ ```
17
+
18
+ ```ruby:placeholder
19
+ ```
20
+
21
+ ### Deleting a Database
22
+
23
+ ```ruby:runnable
24
+ client.dsl.destroy_database!('radioactive')
25
+ ```
26
+
27
+ ```ruby:placeholder
28
+ ```
29
+
30
+ ### Listing Databases
31
+
32
+ ```ruby
33
+ client.dsl.databases
34
+ ```
35
+
36
+ ```ruby
37
+ ['my-datomic-database']
38
+ ```
39
+
40
+ ### Transacting Schema
41
+
42
+ Like `CREATE TABLE` in SQL databases or defining document or record structures in other databases.
43
+
44
+ ```ruby:runnable
45
+ client.dsl.transact_schema!(
46
+ {
47
+ book: {
48
+ title: { type: :string, doc: 'The title of the book.' },
49
+ genre: { type: :string, doc: 'The genre of the book.' },
50
+ published_at_year: { type: :long, doc: 'The year the book was first published.' }
51
+ }
52
+ })
53
+ ```
54
+
55
+ ```ruby:placeholder
56
+ ```
57
+
58
+ ### Checking Schema
59
+
60
+ Like `SHOW COLUMNS FROM` in SQL databases or checking document or record structures in other databases.
61
+
62
+ ```ruby:runnable
63
+ client.dsl.schema
64
+ ```
65
+
66
+ ```ruby:placeholder
67
+
68
+ ```
69
+
70
+ ### Asserting Facts
71
+
72
+ Like `INSERT INTO` in SQL databases or creating a new document or record in other databases.
73
+
74
+ ```ruby:runnable
75
+ client.dsl.assert_into!(
76
+ :book,
77
+ { title: 'Pride and Prejudice',
78
+ genre: 'Romance',
79
+ published_at_year: 1813 }
80
+ )
81
+ ```
82
+
83
+ ```ruby:placeholder
84
+ ```
85
+
86
+ ```ruby:runnable
87
+ client.dsl.assert_into!(
88
+ :book,
89
+ [{ title: 'Near to the Wild Heart',
90
+ genre: 'Novel',
91
+ published_at_year: 1943 },
92
+ { title: 'A Study in Scarlet',
93
+ genre: 'Detective',
94
+ published_at_year: 1887 },
95
+ { title: 'The Tell-Tale Heart',
96
+ genre: 'Horror',
97
+ published_at_year: 1843 }]
98
+ )
99
+ ```
100
+
101
+ ```ruby:state
102
+ state[:wild_heart_entity_id] = result[0]
103
+ state[:scarlet_entity_id] = result[1]
104
+ ```
105
+
106
+ ```ruby:placeholder
107
+ ```
108
+
109
+ ### Reading Data by Entity
110
+
111
+ Like `SELECT` in SQL databases or querying documents or records in other databases.
112
+
113
+ ```ruby:runnable/render
114
+ client.dsl.find_by_entity_id({{ state.wild_heart_entity_id }})
115
+ ```
116
+
117
+ ```ruby:placeholder
118
+ ```
119
+
120
+ ### Reading Data by Querying
121
+
122
+ Like `SELECT` in SQL databases or querying documents or records in other databases.
123
+
124
+ ```ruby:runnable
125
+ client.dsl.query(
126
+ datalog: <<~EDN
127
+ [:find ?e ?title ?genre ?year
128
+ :where [?e :book/title ?title]
129
+ [?e :book/genre ?genre]
130
+ [?e :book/published_at_year ?year]]
131
+ EDN
132
+ )
133
+ ```
134
+
135
+ ```ruby:placeholder
136
+ ```
137
+
138
+ ```ruby:runnable
139
+ client.dsl.query(
140
+ params: ['The Tell-Tale Heart'],
141
+ datalog: <<~EDN
142
+ [:find ?e ?title ?genre ?year
143
+ :in $ ?title
144
+ :where [?e :book/title ?title]
145
+ [?e :book/genre ?genre]
146
+ [?e :book/published_at_year ?year]]
147
+ EDN
148
+ )
149
+ ```
150
+
151
+ ```ruby:state
152
+ state[:tale_heart_entity_id] = result[0][0]
153
+ ```
154
+
155
+ ```ruby:placeholder
156
+ ```
157
+
158
+ ### Accumulating Facts
159
+
160
+ Like `UPDATE` in SQL databases or updating documents or records in other databases. However, Datomic never updates data. It is an immutable database that only accumulates new facts or retracts past facts.
161
+
162
+ ```ruby:runnable/render
163
+ client.dsl.assert_into!(
164
+ :book, { _id: {{ state.tale_heart_entity_id }}, genre: 'Gothic' }
165
+ )
166
+ ```
167
+
168
+ ```ruby:placeholder
169
+ ```
170
+
171
+ ### Retracting Facts
172
+
173
+ Like `DELETE` in SQL databases or deleting documents or records in other databases. However, Datomic never deletes data. It is an immutable database that only accumulates new facts or retracts past facts.
174
+
175
+ Retract the value of an attribute:
176
+
177
+ ```ruby:runnable/render
178
+ client.dsl.retract_from!(
179
+ :book, { _id: {{ state.tale_heart_entity_id }}, genre: 'Gothic' }
180
+ )
181
+ ```
182
+
183
+ ```ruby:placeholder
184
+ ```
185
+
186
+ Retract an attribute:
187
+
188
+ ```ruby:runnable/render
189
+ client.dsl.retract_from!(
190
+ :book, { _id: {{ state.wild_heart_entity_id }}, genre: nil }
191
+ )
192
+ ```
193
+
194
+ ```ruby:placeholder
195
+ ```
196
+
197
+ Retract an entity:
198
+
199
+ ```ruby:runnable/render
200
+ client.dsl.retract_from!(
201
+ :book, { _id: {{ state.scarlet_entity_id }} }
202
+ )
203
+ ```
204
+
205
+ ```ruby:placeholder
206
+ ```
data/helpers/h.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module H
5
+ def self.symbolize_keys(structure)
6
+ result = {}
7
+
8
+ structure.each do |key, value|
9
+ string_key = key.to_sym
10
+
11
+ result[string_key] = value.is_a?(Hash) ? symbolize_keys(value) : value
12
+ end
13
+
14
+ result
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module DangerousOverrideLogic
5
+ CONNECTION_DATABASE_KEYS = [:name].freeze
6
+
7
+ DATABASE_KEYS = %i[name latest as_of].freeze
8
+
9
+ def self.apply_dangerous_overrides_to_payload(path, overrides, payload)
10
+ case path
11
+ when 'datomic/create-database', 'datomic/delete-database',
12
+ 'datomic/get-database-names', 'datomic/list-databases',
13
+ 'meta',
14
+ 'datomic/_debug/as-peer/create-database',
15
+ 'datomic/_debug/as-peer/delete-database'
16
+ payload
17
+ when 'datomic/transact'
18
+ inject_connection_overrides(overrides, payload)
19
+ when 'datomic/entity', 'datomic/datoms'
20
+ inject_database_overrides(overrides, payload)
21
+ when 'datomic/q'
22
+ inject_database_overrides_into_inputs(overrides, payload)
23
+ else
24
+ raise "Unexpected path: '#{path}'"
25
+ end
26
+ end
27
+
28
+ def self.inject_connection_overrides(overrides, payload)
29
+ if !overrides.key?(:database) || overrides[:database].slice(
30
+ *CONNECTION_DATABASE_KEYS
31
+ ).empty?
32
+ return payload
33
+ end
34
+
35
+ payload[:connection] = {} unless payload.key?(:connection)
36
+
37
+ payload[:connection][:database] = {} unless payload[:connection].key?(:database)
38
+
39
+ payload[:connection][:database] = payload[:connection][:database].merge(
40
+ overrides[:database].slice(*CONNECTION_DATABASE_KEYS)
41
+ )
42
+
43
+ payload
44
+ end
45
+
46
+ def self.inject_overrides_into_input(overrides, input)
47
+ input_has_latest = input[:database].key?(:latest)
48
+
49
+ input_has_as_of = input[:database].key?(:as_of)
50
+
51
+ overrides_has_as_of = overrides[:database].key?(:as_of)
52
+ overrides_has_latest = overrides[:database].key?(:latest)
53
+
54
+ input[:database] = input[:database].except(:latest) if input_has_latest && overrides_has_as_of
55
+
56
+ input[:database] = input[:database].except(:as_of) if input_has_as_of && overrides_has_latest
57
+
58
+ input[:database] = input[:database].merge(
59
+ overrides[:database].slice(*DATABASE_KEYS)
60
+ )
61
+
62
+ input
63
+ end
64
+
65
+ def self.inject_database_overrides_into_inputs(overrides, payload)
66
+ if !overrides.key?(:database) || overrides[:database].slice(
67
+ *DATABASE_KEYS
68
+ ).empty?
69
+ return payload
70
+ end
71
+
72
+ payload[:inputs] = {} unless payload.key?(:inputs)
73
+
74
+ index_for_database = payload[:inputs].index do |input|
75
+ input.key?(:database)
76
+ end
77
+
78
+ if index_for_database.nil?
79
+ require 'pry'
80
+ binding.pry
81
+ end
82
+
83
+ payload[:inputs][index_for_database] = inject_overrides_into_input(
84
+ overrides,
85
+ payload[:inputs][index_for_database]
86
+ )
87
+
88
+ payload
89
+ end
90
+
91
+ def self.inject_database_overrides(overrides, payload)
92
+ if !overrides.key?(:database) || overrides[:database].slice(
93
+ *DATABASE_KEYS
94
+ ).empty?
95
+ return payload
96
+ end
97
+
98
+ payload[:database] = {} unless payload.key?(:database)
99
+
100
+ payload[:database] = inject_overrides_into_input(
101
+ overrides,
102
+ { database: payload[:database] }
103
+ )[:database]
104
+
105
+ payload
106
+ end
107
+ end
108
+ end
data/logic/querying.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module QueryingLogic
5
+ def self.entity_to_dsl(entity)
6
+ namespace = entity.keys.find { |key| key != ':db/id' }
7
+
8
+ return nil if namespace.nil?
9
+
10
+ namespace = namespace.split('/').first.sub(/^:/, '').to_sym
11
+
12
+ {
13
+ namespace => keys_to_dsl(entity)
14
+ }
15
+ end
16
+
17
+ def self.keys_to_dsl(entity)
18
+ result = {}
19
+
20
+ entity.each do |key, value|
21
+ # TODO: Is this correct? Should the 'id' exist?
22
+ dsl_key = if [':db/id', 'id'].include?(key)
23
+ :_id
24
+ else
25
+ key.split('/').last.to_sym
26
+ end
27
+
28
+ result[dsl_key] = value.is_a?(Hash) ? keys_to_dsl(value) : value
29
+ end
30
+
31
+ result
32
+ end
33
+ end
34
+ end
data/logic/schema.rb ADDED
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/h'
4
+
5
+ require_relative 'types'
6
+
7
+ module Flare
8
+ module SchemaLogic
9
+ QUERY = <<~EDN
10
+ [:find
11
+ ?e ?ident ?value_type ?cardinality ?doc
12
+ ?unique ?index ?no_history
13
+ :in $
14
+ :where
15
+ [?e :db/ident ?ident]
16
+
17
+ [?e :db/valueType ?value_type_id]
18
+ [?value_type_id :db/ident ?value_type]
19
+
20
+ [?e :db/cardinality ?cardinality_id]
21
+ [?cardinality_id :db/ident ?cardinality]
22
+
23
+ [(get-else $ ?e :db/doc "") ?doc]
24
+
25
+ [(get-else $ ?e :db/unique -1) ?unique_id]
26
+ [(get-else $ ?unique_id :db/ident false) ?unique]
27
+
28
+ [(get-else $ ?e :db/index false) ?index]
29
+ [(get-else $ ?e :db/noHistory false) ?no_history]]
30
+ EDN
31
+
32
+ NON_SCHEMA_NAMESPACES = %w[
33
+ db
34
+ db.alter db.attr db.bootstrap db.cardinality db.entity db.excise
35
+ db.fn db.install db.lang db.part db.sys db.type db.unique
36
+ fressian
37
+ ].freeze
38
+
39
+ def self.specification_to_edn(specification)
40
+ edn_schema = specification.flat_map do |namespace, attributes|
41
+ attributes.map.with_index do |(attribute, options), i|
42
+ fields = [
43
+ "#{i.zero? ? '' : ' '}{:db/ident :#{namespace}/#{attribute}",
44
+ " :db/valueType #{TypesLogic.ruby_to_datomic_type(options[:type])}",
45
+ " :db/cardinality #{TypesLogic.ruby_to_datomic_cardinality(options[:cardinality] || :one)}"
46
+ ]
47
+
48
+ fields << " :db/doc \"#{options[:doc]}\"" if options[:doc]
49
+ fields << " :db/unique #{TypesLogic.ruby_to_datomic_unique(options[:unique])}" if options[:unique]
50
+ fields << ' :db/index true' if options[:index]
51
+ fields << ' :db/noHistory true' if options[:history] == false
52
+
53
+ fields[fields.size - 1] = "#{fields.last}}"
54
+
55
+ fields.join("\n")
56
+ end
57
+ end.join("\n\n")
58
+
59
+ "[#{edn_schema}]"
60
+ end
61
+
62
+ def self.datoms_to_specification(datoms)
63
+ specification = {}
64
+
65
+ datoms.filter do |datom|
66
+ !NON_SCHEMA_NAMESPACES.include?(datom[1].split('/').first)
67
+ end.each do |entry|
68
+ namespace, attribute = entry[1].split('/')
69
+ type = TypesLogic.datomic_to_ruby_type(entry[2])
70
+ cardinality = TypesLogic.datomic_to_ruby_cardinality(entry[3])
71
+ doc = entry[4].empty? ? nil : entry[4]
72
+
73
+ unique = entry[5] ? TypesLogic.datomic_to_ruby_unique(entry[5]) : false
74
+ indexed = entry[6]
75
+ no_history = entry[7]
76
+
77
+ specification[namespace] ||= {}
78
+ specification[namespace][attribute] = {
79
+ type:,
80
+ cardinality:,
81
+ doc:,
82
+ unique:,
83
+ index: indexed,
84
+ history: !no_history
85
+ }
86
+ end
87
+
88
+ H.symbolize_keys(specification)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+
5
+ module Flare
6
+ module TransactingLogic
7
+ def self.retractions_to_edn(namespace, retractions)
8
+ edn = retractions.map do |retraction|
9
+ retraction_to_edn(namespace, retraction)
10
+ end
11
+
12
+ "[#{edn.join("\n ")}]"
13
+ end
14
+
15
+ def self.retraction_to_edn(namespace, retraction)
16
+ id = retraction[:_id]
17
+
18
+ attributes = retraction.except(:_id)
19
+
20
+ if attributes.empty?
21
+ # Built-In Transaction Functions
22
+ # https://docs.datomic.com/transactions/transaction-functions.html#built-in
23
+ "[:db/retractEntity #{id}]"
24
+ else
25
+ attributes.map do |attribute, value|
26
+ if value.nil?
27
+ "[:db/retract #{id} :#{namespace}/#{attribute}]"
28
+ else
29
+ "[:db/retract #{id} :#{namespace}/#{attribute} #{TypesLogic.to_datomic_value(value)}]"
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.transactions_to_edn(namespace, transactions)
36
+ edn = transactions.map do |transaction|
37
+ attributes = transaction.map.with_index do |(attribute, value), i|
38
+ ident = if %i[_id _temporary_id].include?(attribute)
39
+ ':db/id'
40
+ else
41
+ ":#{namespace}/#{attribute}"
42
+ end
43
+
44
+ "#{i.zero? ? '' : ' '}#{ident} #{TypesLogic.to_datomic_value(value)}"
45
+ end.join("\n")
46
+
47
+ "{#{attributes}}"
48
+ end.join("\n ")
49
+
50
+ "[#{edn}]"
51
+ end
52
+ end
53
+ end
data/logic/types.rb ADDED
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'bigdecimal'
5
+
6
+ module Flare
7
+ module TypesLogic
8
+ def self.to_datomic_value(value)
9
+ case value
10
+ when String
11
+ to_datomic_string(value)
12
+ when Integer
13
+ value.to_s
14
+ when Float
15
+ value.to_s
16
+ when BigDecimal
17
+ # https://github.com/relevance/edn-ruby/blob/master/lib/edn/core_ext.rb#L25
18
+ "#{value.to_s('F')}M"
19
+ when Integer, Float, TrueClass, FalseClass
20
+ value.to_s
21
+ when Time, DateTime, Date
22
+ to_datomic_instant(value)
23
+ when Symbol
24
+ ":#{value}"
25
+ when Array
26
+ '[' + value.map { |v| to_datomic_value(v) }.join(' ') + ']'
27
+ when NilClass
28
+ 'nil'
29
+ when Hash
30
+ raise ArgumentError, "Missing :_id for reference: #{value.class}" unless value.key?(:_id)
31
+
32
+ "{:db/id #{value[:_id]}}"
33
+ else
34
+ raise ArgumentError, "Unsupported value type: #{value.class}"
35
+ end
36
+ end
37
+
38
+ def self.to_datomic_string(value)
39
+ # https://github.com/relevance/edn-ruby/blob/master/lib/edn/core_ext.rb#L36
40
+ array = value.chars.map do |ch|
41
+ if %w[" \\].include?(ch)
42
+ "\\#{ch}"
43
+ else
44
+ ch
45
+ end
46
+ end
47
+ "\"#{array.join}\""
48
+ end
49
+
50
+ def self.to_datomic_instant(value)
51
+ time = case value
52
+ when Time
53
+ value
54
+ when Date
55
+ value.to_time
56
+ end
57
+
58
+ "#inst \"#{time.utc.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')}\""
59
+ end
60
+
61
+ def self.ruby_to_datomic_type(ruby_type)
62
+ case ruby_type
63
+ when :string then ':db.type/string'
64
+ when :long then ':db.type/long'
65
+ when :boolean then ':db.type/boolean'
66
+ when :double then ':db.type/double'
67
+ when :instant then ':db.type/instant'
68
+ when :keyword then ':db.type/keyword'
69
+ when :uuid then ':db.type/uuid'
70
+ when :ref then ':db.type/ref'
71
+ when :bigdec then ':db.type/bigdec'
72
+ when :bigint then ':db.type/bigint'
73
+ when :uri then ':db.type/uri'
74
+ else
75
+ raise ArgumentError, "Unknown type: #{ruby_type}"
76
+ end
77
+ end
78
+
79
+ def self.ruby_to_datomic_cardinality(cardinality)
80
+ case cardinality
81
+ when :one then ':db.cardinality/one'
82
+ when :many then ':db.cardinality/many'
83
+ else
84
+ raise ArgumentError, "Unknown cardinality: #{cardinality}"
85
+ end
86
+ end
87
+
88
+ def self.ruby_to_datomic_unique(unique_type)
89
+ case unique_type
90
+ when :identity then ':db.unique/identity'
91
+ when :value then ':db.unique/value'
92
+ when nil then nil
93
+ else
94
+ raise ArgumentError, "Unknown uniqueness constraint: #{unique_type}"
95
+ end
96
+ end
97
+
98
+ def self.datomic_to_ruby_type(datomic_type)
99
+ datomic_type = ":#{datomic_type}" unless datomic_type.start_with?(':')
100
+ case datomic_type
101
+ when ':db.type/bigdec' then :bigdec
102
+ when ':db.type/bigint' then :bigint
103
+ when ':db.type/boolean' then :boolean
104
+ when ':db.type/bytes' then :bytes
105
+ when ':db.type/double' then :double
106
+ when ':db.type/float' then :float
107
+ when ':db.type/instant' then :instant
108
+ when ':db.type/keyword' then :keyword
109
+ when ':db.type/long' then :long
110
+ when ':db.type/ref' then :ref
111
+ when ':db.type/string' then :string
112
+ when ':db.type/symbol' then :symbol
113
+ when ':db.type/tuple' then :tuple
114
+ when ':db.type/uuid' then :uuid
115
+ when ':db.type/uri' then :uri
116
+ else
117
+ raise ArgumentError, "Unknown Datomic type: #{datomic_type}"
118
+ end
119
+ end
120
+
121
+ def self.datomic_to_ruby_unique(datomic_unique)
122
+ datomic_unique = ":#{datomic_unique}" unless datomic_unique.start_with?(':')
123
+ case datomic_unique
124
+ when ':db.unique/value' then :value
125
+ when ':db.unique/identity' then :identity
126
+ else
127
+ raise ArgumentError, "Unknown Datomic uniqueness: #{datomic_unique}"
128
+ end
129
+ end
130
+
131
+ def self.datomic_to_ruby_cardinality(datomic_cardinality)
132
+ datomic_cardinality = ":#{datomic_cardinality}" unless datomic_cardinality.start_with?(':')
133
+ case datomic_cardinality
134
+ when ':db.cardinality/one' then :one
135
+ when ':db.cardinality/many' then :many
136
+ else
137
+ raise ArgumentError, "Unknown Datomic cardinality: #{datomic_cardinality}"
138
+ end
139
+ end
140
+ end
141
+ end
data/ports/cli.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv/load'
4
+
5
+ require_relative '../controllers/documentation/generator'
6
+
7
+ module Flare
8
+ module CLI
9
+ def self.handle(command)
10
+ case command
11
+ when 'docs:generate'
12
+ Flare::Controllers::Documentation::Generator.handler
13
+ else
14
+ puts 'Invalid command.'
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ if __FILE__ == $PROGRAM_NAME
21
+ if ARGV.empty?
22
+ puts 'No command provided.'
23
+ else
24
+ Flare::CLI.handle(ARGV[0])
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../components/errors'
4
+
5
+ include Flare::Errors
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uuidx'
4
+
5
+ require_relative '../../static/gem'
6
+ require_relative '../../controllers/client'
7
+
8
+ module Flare
9
+ def self.new(...)
10
+ Controllers::Client.new(...)
11
+ end
12
+
13
+ def self.uuid
14
+ Uuidx
15
+ end
16
+
17
+ def self.version
18
+ Flare::GEM[:version]
19
+ end
20
+ end