lowdata 0.1.0 → 0.3.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.
@@ -1,11 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LowData
4
- # A table named by a constant, such as `Users`, waiting for its columns: `Users[:username]`.
4
+ # A table named by a constant, such as `Users`. Square brackets choose its columns, `Users[:username]`, and on its own
5
+ # it's every column. See ADR 0006.
5
6
  # An inverted reference is `Table`, standing in for the repository's table and any table related to it.
6
7
  # A reference can carry filters from round brackets, `Posts(published: true)`, and be negated with `~Posts`.
7
8
  class TableReference
8
9
  FILTER_KEYWORD_MESSAGE = 'Filters go in round brackets, e.g. Posts(published: true)[:title], not %<keywords>s'
10
+ BARE_TABLE_MESSAGE = 'Table joins the tables that own the columns it selects, so name them, e.g. Table[:title, :username]. ' \
11
+ "For every column of the repository's table, name the table, e.g. Posts"
12
+ OPERATORS = %i[+ & < > <=> ^ * /].freeze
13
+
14
+ # A bare table on its own is every column, as a data expression: `Users[:username] + Posts`.
15
+ def self.expression(value) = value.is_a?(TableReference) ? value.to_expression : value
9
16
 
10
17
  attr_reader :table, :filter, :negated
11
18
 
@@ -17,18 +24,22 @@ class LowData
17
24
  end
18
25
 
19
26
  # Each argument's type decides its meaning:
20
- # Posts[:title] # A symbol is a column. No columns selects every column.
21
- # Users[:username, Posts[:title]] # A data expression is a nested table.
27
+ # Posts[:title] # A symbol is a column. Empty brackets select no columns.
28
+ # Users[:username, Posts[:title]] # A data expression is a nested table, and a bare table nests
29
+ # Users[:username, Posts] # every column.
22
30
  # Reviews[:rating, author: Users[:username]] # A keyword with a data expression names a nested table.
31
+ # Posts[:id, headline: :title] # A keyword with a symbol renames a column.
23
32
  def [](*arguments, **keywords)
24
- raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
33
+ check_negated!
25
34
  return inverted(arguments:, keywords:) if @inverted
26
35
 
36
+ arguments = arguments.map { |argument| TableReference.expression(argument) }
27
37
  expressions, columns = arguments.partition { |argument| argument.is_a?(DataExpression) }
28
38
  check_columns!(columns:)
29
- nested = nested(pairs: expressions.map { |expression| [nil, expression] } + named(keywords:))
39
+ named, renames = named(keywords:, columns:)
40
+ nested = nested(pairs: expressions.map { |expression| [nil, expression] } + named)
30
41
 
31
- DataExpression.new(node: DataExpression::Source.new(table:, columns:, nested:, filter:))
42
+ DataExpression.new(node: DataExpression::Source.new(table:, columns:, renames:, nested:, filter:))
32
43
  end
33
44
 
34
45
  # Each argument's type decides its meaning:
@@ -37,8 +48,9 @@ class LowData
37
48
  # Users(Posts), Users(~Posts) # A table keeps rows with, or without, related rows.
38
49
  # Users(Posts => 3..) # A table as a key keeps rows with that many related rows.
39
50
  # Reviews(author: Users(username: 'alice')) # A keyword with a table names the foreign key to follow.
51
+ # Posts(:user_id) # A symbol names the foreign key a nested join links by.
40
52
  def where(*arguments, **keywords, &block)
41
- related = arguments.map { |argument| related(key: nil, reference: argument) }
53
+ related = arguments.grep_v(Symbol).map { |argument| related(key: nil, reference: argument) }
42
54
  values = {}
43
55
 
44
56
  keywords.each do |key, value|
@@ -49,7 +61,7 @@ class LowData
49
61
  end
50
62
 
51
63
  conditions = block ? [Sequel.virtual_row(&block)] : []
52
- with(filter: filter.merge(DataExpression::Filter.new(values:, conditions:, related:)))
64
+ with(filter: filter.merge(DataExpression::Filter.new(values:, conditions:, related:, foreign_keys: arguments.grep(Symbol))))
53
65
  end
54
66
 
55
67
  # `~Posts` keeps rows without related posts.
@@ -57,12 +69,35 @@ class LowData
57
69
  with(negated: !negated)
58
70
  end
59
71
 
72
+ # A bare table reads and joins as every column of the table:
73
+ # Posts # SELECT posts.* FROM posts
74
+ # Users[:username] + Posts # Each user with every column of their posts.
75
+ # Posts ^ -Col[:views] * 10 # The 10 most viewed posts.
76
+ def to_expression
77
+ raise ArgumentError, BARE_TABLE_MESSAGE if @inverted
78
+
79
+ check_negated!
80
+ DataExpression.new(node: source)
81
+ end
82
+
83
+ OPERATORS.each do |operator|
84
+ define_method(operator) { |other| to_expression.public_send(operator, other) }
85
+ end
86
+
87
+ %i[to_a sql dataset].each do |name|
88
+ define_method(name) { |**options| to_expression.public_send(name, **options) }
89
+ end
90
+
60
91
  def source
61
- DataExpression::Source.new(table:, columns: [], filter:)
92
+ DataExpression::Source.new(table:, columns: [], filter:, bare: true)
62
93
  end
63
94
 
64
95
  private
65
96
 
97
+ def check_negated!
98
+ raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
99
+ end
100
+
66
101
  def with(filter: @filter, negated: @negated)
67
102
  self.class.new(table:, inverted: @inverted, filter:, negated:)
68
103
  end
@@ -76,11 +111,20 @@ class LowData
76
111
  DataExpression::Related.new(key:, source: reference.source, count: count || (reference.negated ? 0 : 1..))
77
112
  end
78
113
 
79
- def named(keywords:)
80
- named, filters = keywords.partition { |_key, value| value.is_a?(DataExpression) }
114
+ # Returns the named nested tables and the renamed columns.
115
+ def named(keywords:, columns:)
116
+ keywords = keywords.transform_values { |value| TableReference.expression(value) }
117
+ named, others = keywords.partition { |_key, value| value.is_a?(DataExpression) }
118
+ renames, filters = others.partition { |_key, value| value.is_a?(Symbol) }
81
119
  raise ArgumentError, format(FILTER_KEYWORD_MESSAGE, keywords: filters.to_h) if filters.any?
82
120
 
83
- named
121
+ check_renames!(names: columns + named.map(&:first), renames: renames.to_h)
122
+ [named, renames.to_h]
123
+ end
124
+
125
+ def check_renames!(names:, renames:)
126
+ duplicate = (renames.keys & names).first
127
+ raise ArgumentError, "#{LowData.constant_name(table)}[] returns #{duplicate} twice. Name each value once" if duplicate
84
128
  end
85
129
 
86
130
  def nested(pairs:)
@@ -88,10 +132,13 @@ class LowData
88
132
  end
89
133
 
90
134
  def inverted(arguments:, keywords:)
135
+ raise ArgumentError, BARE_TABLE_MESSAGE if arguments.empty? && keywords.empty?
136
+
91
137
  check_columns!(columns: arguments)
92
- raise ArgumentError, 'Table[] takes column names only' if keywords.any?
138
+ raise ArgumentError, 'Table[] takes column names only, and renames such as headline: :title' unless keywords.values.all?(Symbol)
93
139
 
94
- DataExpression.new(node: DataExpression::Inversion.new(table:, columns: arguments, filter:))
140
+ check_renames!(names: arguments, renames: keywords)
141
+ DataExpression.new(node: DataExpression::Inversion.new(table:, columns: arguments, renames: keywords, filter:))
95
142
  end
96
143
 
97
144
  def check_columns!(columns:)
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LowData
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Checks a write before it runs, so a mistake raises rather than writing the wrong rows. See Writer.
5
+ module WriteChecks
6
+ ROWS_MESSAGE = '%<operator>s takes a hash or an array of hashes, e.g. Posts %<operator>s { title: \'Hello\' }, not %<rows>s'
7
+ INSERT_FILTER_MESSAGE = 'An insert adds new rows, so it takes no round brackets. To insert a row or update the one ' \
8
+ 'matching the round brackets, upsert with <=, e.g. Posts(slug: \'hello\') <= { title: \'Hello\' }'
9
+ UPSERT_FILTER_MESSAGE = 'An upsert matches one row by plain values, e.g. Posts(slug: \'hello\') <= row, so it takes no ' \
10
+ 'ranges, lists, blocks or tables in round brackets'
11
+ FOREIGN_KEY_MESSAGE = "A symbol in round brackets names a foreign key for a nested join, so it can't choose rows to %<action>s"
12
+ # `Posts[:id] <= Users[:id]` reads like a comparison or a join, so it says what `<=` does instead.
13
+ EXPRESSION_MESSAGES = {
14
+ '<<' => "<< inserts rows, e.g. Posts << { title: 'Hello' }, not a data expression",
15
+ '<=' => "<= upserts rows, e.g. Posts(slug: 'hello') <= { title: 'Hello' }, so it can't compare or join data expressions. " \
16
+ 'Join them with &, <, > or <=>'
17
+ }.freeze
18
+
19
+ private
20
+
21
+ def check_rows!(rows:, operator:)
22
+ return if rows.is_a?(Hash) || (rows.is_a?(Array) && rows.all?(Hash))
23
+ raise ArgumentError, EXPRESSION_MESSAGES.fetch(operator) if rows.is_a?(DataExpression)
24
+
25
+ raise ArgumentError, format(ROWS_MESSAGE, operator:, rows: rows.inspect)
26
+ end
27
+
28
+ def check_insert_filter!(filter:)
29
+ raise ArgumentError, INSERT_FILTER_MESSAGE if chooses_rows?(filter:)
30
+ end
31
+
32
+ # Whether the round brackets choose rows. Foreign keys only name how a nested join links.
33
+ def chooses_rows?(filter:)
34
+ filter.values.any? || filter.conditions.any? || filter.related.any?
35
+ end
36
+
37
+ def check_upsert_filter!(filter:)
38
+ plain = filter.values.values.none? { |value| value.is_a?(Range) || value.is_a?(Enumerable) || value.is_a?(Sequel::SQL::Expression) }
39
+ return if plain && filter.conditions.empty? && filter.related.empty? && filter.foreign_keys.empty?
40
+
41
+ raise ArgumentError, UPSERT_FILTER_MESSAGE
42
+ end
43
+
44
+ # An update or delete without round brackets would change every row, so it raises. `dataset` can still do that.
45
+ def check_chosen!(source:, action:, example:)
46
+ filter = source.filter
47
+ raise ArgumentError, format(FOREIGN_KEY_MESSAGE, action:) if filter.foreign_keys.any?
48
+ return if chooses_rows?(filter:)
49
+
50
+ raise ArgumentError, "Choose the #{source.table} to #{action} with round brackets, e.g. #{example}"
51
+ end
52
+
53
+ # MySQL's ON DUPLICATE KEY UPDATE matches any unique key, so the upsert must match on one, and the row mustn't set
54
+ # every column of another, which could match a different row. An auto-increment id left out of the row can't match.
55
+ def check_mysql_keys!(table:, keys:, row:, target:)
56
+ matched, others = keys.partition { |key| key.sort == target.sort }
57
+ raise Error, "MySQL upserts match on a unique key, and #{target.join(', ')} isn't one of #{table}'s" if matched.empty?
58
+
59
+ clashing = others.find { |key| key.none? { |column| row[column].nil? } }
60
+ return unless clashing
61
+
62
+ raise Error, "MySQL matches an upsert on any unique key, and the row also sets #{clashing.join(', ')}, which could match " \
63
+ "another #{table} row. Leave #{clashing.join(', ')} out of the row, or match on it"
64
+ end
65
+
66
+ # Checked before the write, so a typo doesn't write a row and then fail to return it.
67
+ def check_returned!(source:, columns:)
68
+ missing = (source.columns + source.renames.values) - columns
69
+ raise ArgumentError, "#{source.table} has no #{missing.join(', ')} column to return" if missing.any?
70
+ end
71
+
72
+ def assignment(values:)
73
+ return "[:#{values.keys.first}] = #{values.values.first.inspect}" if values.one?
74
+
75
+ "[] = #{values.inspect}"
76
+ end
77
+ end
78
+ end
data/lib/writer.rb ADDED
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'write_checks'
4
+
5
+ class LowData
6
+ # Runs writes as soon as they're written, and returns what they wrote. See ADR 0007:
7
+ # Posts << { title: 'Hello' } # INSERT, returning the primary key
8
+ # Posts[:id, :title] << { title: 'Hello' } # INSERT, returning the columns in square brackets
9
+ # Posts(slug: 'hello') <= { title: 'Hello' } # INSERT ... ON CONFLICT (slug) DO UPDATE, returning the primary key
10
+ # Posts(id: 1)[:title] = 'New' # UPDATE ... WHERE (posts.id = 1)
11
+ # Posts(id: 1).delete # DELETE ... WHERE (posts.id = 1), returning the number of rows deleted
12
+ class Writer
13
+ include WriteChecks
14
+
15
+ def initialize(database:)
16
+ raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
17
+
18
+ @database = database
19
+ @schema = Schema.new(database:)
20
+ @filters = FilterCompiler.new(database:, schema: @schema)
21
+ end
22
+
23
+ def insert(source:, rows:)
24
+ check_rows!(rows:, operator: '<<')
25
+ check_insert_filter!(filter: source.filter)
26
+ check_returned!(source:, columns: @schema.columns(table: source.table))
27
+ return insert_all(source:, rows:) if source.no_columns? && rows.is_a?(Array) && rows.map(&:keys).uniq.one?
28
+
29
+ each_row(rows:) { |row| insert_row(source:, row:) }
30
+ end
31
+
32
+ # The round brackets name the columns to match on, and their values are written too.
33
+ def upsert(source:, rows:)
34
+ check_rows!(rows:, operator: '<=')
35
+ check_upsert_filter!(filter: source.filter)
36
+ check_returned!(source:, columns: @schema.columns(table: source.table))
37
+
38
+ each_row(rows:) { |row| upsert_row(source:, row: source.filter.values.merge(row)) }
39
+ end
40
+
41
+ def update(source:, values:)
42
+ check_chosen!(source:, action: 'update', example: "#{constant(source:)}(id: 1)#{assignment(values:)}")
43
+
44
+ chosen(source:).update(values)
45
+ end
46
+
47
+ def delete(source:)
48
+ check_chosen!(source:, action: 'delete', example: "#{constant(source:)}(id: 1).delete")
49
+
50
+ chosen(source:).delete
51
+ end
52
+
53
+ private
54
+
55
+ # A hash writes one row and returns a hash, and an array writes each row in a transaction and returns an array.
56
+ def each_row(rows:, &)
57
+ return yield(rows) if rows.is_a?(Hash)
58
+
59
+ @database.transaction { rows.map(&) }
60
+ end
61
+
62
+ # Empty brackets return nothing, so rows with the same columns go in one INSERT, as nothing needs matching back to
63
+ # them. Otherwise rows insert one by one, because RETURNING doesn't promise the inserted order, and a multi-row
64
+ # INSERT takes its columns from the first row, so a column missing from it would be dropped.
65
+ def insert_all(source:, rows:)
66
+ @database[source.table].multi_insert(rows)
67
+ rows.map { {} }
68
+ end
69
+
70
+ def insert_row(source:, row:)
71
+ dataset = @database[source.table]
72
+ return dataset.returning(*returned(source:)).insert(row).first if returning?(dataset:, source:)
73
+
74
+ id = dataset.insert(row)
75
+ read_back(source:, keys: inserted_keys(table: source.table, row:, id:))
76
+ end
77
+
78
+ def upsert_row(source:, row:)
79
+ target = conflict_target(source:, row:)
80
+ return mysql_upsert(source:, row:, target:) if @database.database_type == :mysql
81
+
82
+ dataset = @database[source.table]
83
+ raise Error, "LowData can't upsert on #{@database.database_type}" unless dataset.respond_to?(:insert_conflict)
84
+
85
+ dataset = dataset.insert_conflict(target:, update: updates(row:, target:))
86
+ return dataset.returning(*returned(source:)).insert(row).first if returning?(dataset:, source:)
87
+
88
+ dataset.insert(row)
89
+ read_back(source:, keys: row.slice(*target))
90
+ end
91
+
92
+ def mysql_upsert(source:, row:, target:)
93
+ check_mysql_keys!(table: source.table, keys: unique_keys(table: source.table), row:, target:)
94
+ @database[source.table].on_duplicate_key_update(*updates(row:, target:).keys).insert(row)
95
+ read_back(source:, keys: row.slice(*target))
96
+ end
97
+
98
+ def unique_keys(table:)
99
+ indexes = @database.indexes(table).values.select { |index| index[:unique] }.map { |index| index[:columns] }
100
+ ([@schema.primary_key(table:)] + indexes).reject(&:empty?)
101
+ end
102
+
103
+ # Without round brackets, an upsert matches on the primary key.
104
+ def conflict_target(source:, row:)
105
+ return source.filter.values.keys if source.filter.values.any?
106
+
107
+ primary_key = @schema.primary_key(table: source.table)
108
+ missing = primary_key - row.keys
109
+ return primary_key if primary_key.any? && missing.empty?
110
+
111
+ raise ArgumentError, "#{constant(source:)} <= row matches on the primary key, so the row needs #{missing.join(', ')}. " \
112
+ "Or name the columns to match in round brackets, e.g. #{constant(source:)}(slug: 'hello') <= row"
113
+ end
114
+
115
+ # A row of only the matched columns still updates one of them, so the database returns the row rather than nothing.
116
+ def updates(row:, target:)
117
+ columns = row.keys - target
118
+ (columns.empty? ? [target.first] : columns).to_h { |column| [column, Sequel[:excluded][column]] }
119
+ end
120
+
121
+ # Square brackets choose the columns returned, and a bare table returns its primary key, or every column without one.
122
+ def returned(source:)
123
+ return @schema.primary_key(table: source.table) if source.every_column?
124
+
125
+ source.columns + source.renames.map { |name, column| Sequel.as(column, name) }
126
+ end
127
+
128
+ # Empty brackets return no columns, and databases without RETURNING read the row back by its keys.
129
+ def read_back(source:, keys:)
130
+ return {} if source.no_columns?
131
+
132
+ @database[source.table].where(keys).select(*returned(source:)).first
133
+ end
134
+
135
+ def returning?(dataset:, source:)
136
+ dataset.supports_returning?(:insert) && !source.no_columns?
137
+ end
138
+
139
+ def inserted_keys(table:, row:, id:)
140
+ primary_key = @schema.primary_key(table:)
141
+ return row if primary_key.empty?
142
+ return { primary_key.first => id } if primary_key.one? && !row.key?(primary_key.first)
143
+
144
+ row.slice(*primary_key)
145
+ end
146
+
147
+ def chosen(source:)
148
+ @filters.apply(dataset: @database[source.table], source:)
149
+ end
150
+
151
+ def constant(source:)
152
+ LowData.constant_name(source.table)
153
+ end
154
+ end
155
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lowdata
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.3'
18
+ version: '0.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.3'
25
+ version: '0.4'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: lowtype
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -58,20 +58,28 @@ executables: []
58
58
  extensions: []
59
59
  extra_rdoc_files: []
60
60
  files:
61
+ - config/rubocop.yml
61
62
  - lib/col.rb
62
63
  - lib/data_expression.rb
64
+ - lib/data_expression/source.rb
65
+ - lib/data_expression/writes.rb
63
66
  - lib/errors.rb
64
67
  - lib/filter_compiler.rb
65
68
  - lib/inversion.rb
66
69
  - lib/lowdata.rb
70
+ - lib/nested_join.rb
67
71
  - lib/nesting.rb
72
+ - lib/nesting_path.rb
68
73
  - lib/nesting_tree.rb
69
74
  - lib/order_compiler.rb
70
75
  - lib/query_compiler.rb
71
76
  - lib/rows.rb
72
77
  - lib/schema.rb
73
78
  - lib/table_reference.rb
79
+ - lib/table_reference/writes.rb
74
80
  - lib/version.rb
81
+ - lib/write_checks.rb
82
+ - lib/writer.rb
75
83
  homepage: https://github.com/low-rb/lowdata
76
84
  licenses: []
77
85
  metadata: