lowdata 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4c2e8629cffb84f30179cf393797a47d5cb05f7f03091a2b2cc122d8053593a4
4
+ data.tar.gz: 1468121aa2d7c393a167f890ad009da216191ed2da0d83371ce33e05f3cc4100
5
+ SHA512:
6
+ metadata.gz: fabdfb8df5462fd1274a435bcc4a1230f58cafb17f0ebc6ec03a4652b81b86f61ef877d4237c121510325d0a72280a3de0afcf81490aea61facd1dc6336786c9
7
+ data.tar.gz: 9311a3e886512bf5eb297690db47fb78ff5f81c9d9c267ab1a5b9a289de94cc784909a4b8bf6104ab6841b56cd9c2e4be8f3a13f2bc14f296448389d3f03cc07
data/lib/col.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Columns to sort by with ^, and optionally which of the sorted rows to keep:
5
+ # Posts[:title] ^ Col[:views, :title] # ORDER BY views, title
6
+ # Posts[:title] ^ -Col[:views] # ORDER BY views DESC
7
+ # Posts[:title] ^ -Col[:views] + Col[:title] # ORDER BY views DESC, title
8
+ # Posts[:title] ^ -Col[:views] * 10 # ... LIMIT 10
9
+ # Posts[:title] ^ -Col[:views][20...40] # ... LIMIT 20 OFFSET 20
10
+ # (Posts[:title] ^ -Col[:views] / 20)[2] # ... LIMIT 20 OFFSET 40
11
+ # Ruby binds -, +, *, / and [] tighter than ^, so the rows travel with the sort keys to the expression.
12
+ class Col
13
+ Key = ::Data.define(:column, :descending)
14
+
15
+ attr_reader :keys, :rows
16
+
17
+ def self.[](*columns)
18
+ invalid = columns.reject { |column| column.is_a?(Symbol) }
19
+ raise ArgumentError, "Col[] takes column names, e.g. Col[:views], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
20
+ raise ArgumentError, 'Col[] takes at least one column, e.g. Col[:views]' if columns.empty?
21
+
22
+ new(keys: columns.map { |column| Key.new(column:, descending: false) })
23
+ end
24
+
25
+ def initialize(keys:, rows: nil)
26
+ @keys = keys
27
+ @rows = rows
28
+ freeze
29
+ end
30
+
31
+ def -@
32
+ with(keys: keys.map { |key| key.with(descending: !key.descending) })
33
+ end
34
+
35
+ def +(other)
36
+ raise ArgumentError, "Add sort columns with Col[], e.g. -Col[:views] + Col[:title], not #{other.inspect}" unless other.is_a?(Col)
37
+
38
+ with(keys: keys + other.keys, rows: Rows.merge(rows, other.rows))
39
+ end
40
+
41
+ def *(count)
42
+ with(rows: Rows.merge(rows, Rows.limit(count)))
43
+ end
44
+
45
+ def /(size)
46
+ with(rows: Rows.merge(rows, Rows.pages(size)))
47
+ end
48
+
49
+ def [](range)
50
+ with(rows: Rows.merge(rows, Rows.range(range)))
51
+ end
52
+
53
+ private
54
+
55
+ def with(keys: @keys, rows: @rows)
56
+ self.class.new(keys:, rows:)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Joins tables with operators and builds a join tree for QueryCompiler:
5
+ # Users[:username, Posts[:title]] # Nested; every user once, with their posts nested. See Nesting.
6
+ # Users[:username] + Posts[:title] # Nested, shorthand for the above.
7
+ # Users[:username] & Posts[:title] # Inner join.
8
+ # Users[:username] < Posts[:title] # Left join.
9
+ # Users[:username] > Posts[:title] # Right join.
10
+ # Users[:username] <=> Posts[:title] # Full outer join.
11
+ # Table[:username, :title] # Inverted; the repository's table joined to whichever tables own the columns.
12
+ # And sorts and limits the result. See Col and Rows:
13
+ # Posts[:title] ^ -Col[:views] * 10 # ORDER BY views DESC LIMIT 10
14
+ # (Posts[:title] / 20)[2] # LIMIT 20 OFFSET 40
15
+ class DataExpression < ::Expressions::Expression
16
+ # The filters in a table's round brackets: `Posts(published: true) { views > 100 }`. See FilterCompiler.
17
+ Filter = ::Data.define(:values, :conditions, :related) do
18
+ def initialize(values: {}, conditions: [], related: [])
19
+ super
20
+ end
21
+
22
+ def merge(other)
23
+ with(values: values.merge(other.values), conditions: conditions + other.conditions, related: related + other.related)
24
+ end
25
+ end
26
+ # A table in another table's round brackets, keeping rows with `count` related rows: `Users(Posts => 3..)`.
27
+ Related = ::Data.define(:key, :source, :count)
28
+ # A table with the columns to select, the tables nested in its brackets and the filter on its rows.
29
+ Source = ::Data.define(:table, :columns, :nested, :filter) do
30
+ def initialize(table:, columns:, nested: [], filter: Filter.new)
31
+ super
32
+ end
33
+ end
34
+ # A table nested in another table's brackets, under an optional key: `Reviews[:rating, author: Users[:username]]`.
35
+ Nested = ::Data.define(:key, :node)
36
+ Join = ::Data.define(:type, :left, :right)
37
+ Inversion = ::Data.define(:table, :columns, :filter)
38
+ # A node sorted by `keys` (Col::Key) with `rows` (Rows) kept: `Posts[:title] ^ -Col[:views] * 10`.
39
+ Ordered = ::Data.define(:node, :keys, :rows)
40
+
41
+ JOIN_HOOKS = { add: :nest, intersect: :inner, lt: :left, gt: :right, compare: :full }.freeze
42
+
43
+ attr_reader :node
44
+
45
+ def initialize(node:)
46
+ @node = node
47
+ end
48
+
49
+ SORT_MESSAGE = 'Sort by columns with Col[], e.g. Posts[:title] ^ Col[:views], not %<value>s'
50
+ NESTED_SQL_MESSAGE = '+ loads nested data with one query per table, so it has no single SQL query. Call to_a instead'
51
+
52
+ def dataset(database: LowData.database)
53
+ raise Error, NESTED_SQL_MESSAGE if nested?
54
+
55
+ QueryCompiler.new(database:).compile(node: @node)
56
+ end
57
+
58
+ def sql(database: LowData.database)
59
+ dataset(database:).sql
60
+ end
61
+
62
+ def to_a(database: LowData.database)
63
+ return Nesting.new(database:).load(node: @node) if nested?
64
+
65
+ dataset(database:).all
66
+ end
67
+
68
+ # Picks a page after splitting rows into pages: `(Posts[:title] / 20)[2]` is the third page of 20. Returns a new
69
+ # expression, so the same pages can be picked from more than once.
70
+ def [](page)
71
+ ordered = @node if @node.is_a?(Ordered)
72
+ raise ArgumentError, 'Split rows into pages before picking one, e.g. (Posts[:title] / 20)[0]' unless ordered&.rows
73
+
74
+ DataExpression.new(node: ordered.with(rows: ordered.rows.page(page)))
75
+ end
76
+
77
+ private
78
+
79
+ def nested?
80
+ node = @node.is_a?(Ordered) ? @node.node : @node
81
+ (node.is_a?(Join) && node.type == :nest) || (node.is_a?(Source) && node.nested.any?)
82
+ end
83
+
84
+ # Sorting again adds sort keys after the existing ones, and an expression keeps one limit, range or page size.
85
+ def order(keys:, rows:)
86
+ ordered = @node.is_a?(Ordered) ? @node : Ordered.new(node: @node, keys: [], rows: nil)
87
+ @node = ordered.with(keys: ordered.keys + keys, rows: Rows.merge(ordered.rows, rows))
88
+ end
89
+
90
+ def xor_value(value)
91
+ raise ArgumentError, format(SORT_MESSAGE, value: value.inspect) unless value.is_a?(Col)
92
+
93
+ order(keys: value.keys, rows: value.rows)
94
+ end
95
+
96
+ def multiply_value(count)
97
+ order(keys: [], rows: Rows.limit(count))
98
+ end
99
+
100
+ def divide_value(size)
101
+ order(keys: [], rows: Rows.pages(size))
102
+ end
103
+
104
+ def xor_expression(_expression)
105
+ raise ArgumentError, format(SORT_MESSAGE, value: 'a data expression')
106
+ end
107
+
108
+ def xor_type(type_)
109
+ raise ArgumentError, format(SORT_MESSAGE, value: type_)
110
+ end
111
+
112
+ { multiply: 'Limit', divide: 'Split into pages' }.each do |prefix, action|
113
+ define_method(:"#{prefix}_expression") do |_expression|
114
+ raise ArgumentError, "#{action} by a positive integer, not a data expression"
115
+ end
116
+
117
+ define_method(:"#{prefix}_type") do |type_|
118
+ raise ArgumentError, "#{action} by a positive integer, not the type #{type_}"
119
+ end
120
+ end
121
+
122
+ JOIN_HOOKS.each do |prefix, type|
123
+ define_method(:"#{prefix}_expression") do |expression|
124
+ @node = Join.new(type:, left: @node, right: expression.node)
125
+ end
126
+
127
+ define_method(:"#{prefix}_value") do |value|
128
+ raise ArgumentError, "Join a data expression such as Posts[:title], not the value #{value.inspect}"
129
+ end
130
+
131
+ define_method(:"#{prefix}_type") do |type_|
132
+ raise ArgumentError, "Join a data expression such as Posts[:title], not the type #{type_}"
133
+ end
134
+ end
135
+ end
136
+ end
data/lib/errors.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ class Error < StandardError; end
5
+
6
+ class ConfigError < Error
7
+ MISSING_DATABASE = 'Set LowData.database to a Sequel database, e.g. LowData.database = Sequel.sqlite'
8
+ end
9
+
10
+ class JoinError < Error; end
11
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Compiles a table's filter into WHERE conditions, each qualified with the table:
5
+ # Posts(published: true) # WHERE posts.published IS TRUE
6
+ # Posts { views > 100 } # WHERE posts.views > 100
7
+ # Users(Posts) # WHERE EXISTS (SELECT 1 FROM posts WHERE posts.user_id = users.id)
8
+ # Users(Posts => 3..) # WHERE (SELECT count(*) FROM posts WHERE posts.user_id = users.id) >= 3
9
+ class FilterCompiler
10
+ def initialize(database:, schema:)
11
+ @database = database
12
+ @links = NestingTree.new(schema:)
13
+ end
14
+
15
+ def apply(dataset:, source:)
16
+ conditions(source:).reduce(dataset) { |filtered, condition| filtered.where(condition) }
17
+ end
18
+
19
+ private
20
+
21
+ def conditions(source:)
22
+ table = source.table
23
+ filter = source.filter
24
+ values = filter.values.map { |column, value| { Sequel[table][column] => value } }
25
+ conditions = filter.conditions.map { |condition| Sequel::Qualifier.new(table).transform(condition) }
26
+
27
+ values + conditions + filter.related.map { |related| related_condition(table:, related:) }
28
+ end
29
+
30
+ # Correlates the related table's rows with the outer table's by the foreign key that links them, as nesting does.
31
+ def related_condition(table:, related:)
32
+ inner = related.source.table
33
+ raise JoinError, "Can't filter #{table} by its own rows" if inner == table
34
+
35
+ _key, link = @links.link_to(parent: table, child: inner, key: related.key)
36
+ rows = @database[inner].where(Sequel[inner][link.child_column] => Sequel[table][link.parent_column])
37
+ count(rows: apply(dataset: rows, source: related.source), count: related.count)
38
+ end
39
+
40
+ def count(rows:, count:)
41
+ return rows.select(1).exists if count == (1..)
42
+ return ~rows.select(1).exists if [0, 0..0].include?(count)
43
+
44
+ { rows.select(Sequel.function(:count).*) => count }
45
+ end
46
+ end
47
+ end
data/lib/inversion.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Resolves `Table[:username, :title]` in PostsData to a join tree: posts left joined to each related table that owns a column.
5
+ class Inversion
6
+ def initialize(schema:)
7
+ @schema = schema
8
+ end
9
+
10
+ # Returns the join tree and the columns to select, in the order they were asked for.
11
+ def resolve(node:)
12
+ owners = node.columns.to_h { |column| [column, owner(table: node.table, column:)] }
13
+ base = DataExpression::Source.new(table: node.table, columns: [], filter: node.filter)
14
+
15
+ tree = (owners.values.uniq - [node.table]).reduce(base) do |left, table|
16
+ DataExpression::Join.new(type: :left, left:, right: DataExpression::Source.new(table:, columns: []))
17
+ end
18
+
19
+ [tree, owners.map { |column, table| Sequel[table][column] }]
20
+ end
21
+
22
+ private
23
+
24
+ def owner(table:, column:)
25
+ return table if @schema.columns(table:).include?(column)
26
+
27
+ owners = @schema.related_tables(table:).select { |related| @schema.columns(table: related).include?(column) }
28
+ raise JoinError, "No table related to #{table} has a #{column} column" if owners.empty?
29
+ raise JoinError, ambiguous_message(table:, column:, owners:) if owners.length > 1
30
+
31
+ owners.first
32
+ end
33
+
34
+ def ambiguous_message(table:, column:, owners:)
35
+ "#{owners.join(' and ')} both have a #{column} column and are related to #{table}. " \
36
+ "Join explicitly instead, e.g. #{LowData.constant_name(table)}[] < #{LowData.constant_name(owners.first)}[:#{column}]"
37
+ end
38
+ end
39
+ end
data/lib/lowdata.rb ADDED
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'expressions'
4
+ require 'lowtype'
5
+ require 'sequel'
6
+
7
+ require_relative 'version'
8
+ require_relative 'errors'
9
+ require_relative 'data_expression'
10
+ require_relative 'rows'
11
+ require_relative 'col'
12
+ require_relative 'order_compiler'
13
+ require_relative 'filter_compiler'
14
+ require_relative 'schema'
15
+ require_relative 'inversion'
16
+ require_relative 'nesting'
17
+ require_relative 'nesting_tree'
18
+ require_relative 'query_compiler'
19
+ require_relative 'table_reference'
20
+
21
+ class LowData
22
+ class << self
23
+ attr_reader :database
24
+ attr_writer :table
25
+
26
+ def database=(database)
27
+ @database = database
28
+ @tables = nil
29
+ end
30
+
31
+ # The database's tables, cached until the database changes.
32
+ def tables
33
+ @tables ||= database.tables
34
+ end
35
+
36
+ # The repository's table, named after the class: PostsData is :posts and BlogPostsData is :blog_posts.
37
+ def table
38
+ @table ||= name && table_name(name.split('::').last.delete_suffix('Data'))
39
+ end
40
+
41
+ # Resolve undefined constants in a repository to tables, so `Users` and `BlogPosts` become :users and :blog_posts.
42
+ # `Table` is the repository's own table, joined to whichever related tables own the columns asked for.
43
+ def const_missing(name)
44
+ return super if self == LowData
45
+ return inverted_table if name == :Table
46
+
47
+ TableReference.new(table: table_name(name))
48
+ end
49
+
50
+ # The constant a table is written as in a data expression: :blog_posts is BlogPosts.
51
+ def constant_name(table)
52
+ table.to_s.split('_').map(&:capitalize).join
53
+ end
54
+
55
+ def table_name(constant_name)
56
+ constant_name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
57
+ end
58
+
59
+ private
60
+
61
+ def inverted_table
62
+ raise ConfigError, "Set the table for #{self}, e.g. self.table = :posts" if table.nil?
63
+
64
+ TableReference.new(table:, inverted: true)
65
+ end
66
+ end
67
+
68
+ # Round brackets after a table filter it: `Posts(published: true) { views > 100 }[:title]`. A capitalised name is a
69
+ # table only when the database has that table, so typos still raise NoMethodError. `Table(...)` is always a table.
70
+ def method_missing(name, *arguments, **keywords, &block)
71
+ raise ConfigError, ConfigError::MISSING_DATABASE if LowData.database.nil? && name.match?(/\A[A-Z]/)
72
+ return super unless table_method?(name:)
73
+
74
+ self.class.const_missing(name).where(*arguments, **keywords, &block)
75
+ end
76
+
77
+ def respond_to_missing?(name, include_private = false)
78
+ table_method?(name:) || super
79
+ end
80
+
81
+ private
82
+
83
+ def table_method?(name:)
84
+ return false unless name.match?(/\A[A-Z]/)
85
+ return true if name == :Table
86
+ return false if LowData.database.nil?
87
+
88
+ LowData.tables.include?(LowData.table_name(name))
89
+ end
90
+ end
data/lib/nesting.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Loads nested data, one query per table. Tables nest explicitly in brackets, or by inference with +:
5
+ # Posts[:title, Users[:username]] # => [{ title: 'Hello', user: { username: 'alice' } }]
6
+ # Users[:username] + Posts[:title] # => [{ username: 'alice', posts: [{ title: 'Hello' }] }]
7
+ # In a + chain the first table is the subject, and each other operand nests under the table its foreign key links it to.
8
+ class Nesting
9
+ # A table in the nesting tree. `link` connects it to its parent node: `parent_column` on the parent's rows matches
10
+ # `child_column` on this table's rows, and `many` nests the matches as an array rather than one object.
11
+ # `keys` and `rows` sort and limit the table's rows, per parent when it's nested.
12
+ Node = ::Data.define(:source, :key, :link, :children, :keys, :rows) do
13
+ def initialize(source:, key:, link:, children:, keys: [], rows: nil)
14
+ super
15
+ end
16
+ end
17
+ Link = ::Data.define(:parent_column, :child_column, :many)
18
+
19
+ ROW_NUMBER = :lowdata_row_number
20
+
21
+ def initialize(database:)
22
+ raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
23
+
24
+ @database = database
25
+ @schema = Schema.new(database:)
26
+ @filters = FilterCompiler.new(database:, schema: @schema)
27
+ @orders = OrderCompiler.new(schema: @schema)
28
+ end
29
+
30
+ def load(node:)
31
+ root = NestingTree.new(schema: @schema).build(node:)
32
+ shape(node: root, rows: fetch(node: root))
33
+ end
34
+
35
+ private
36
+
37
+ def fetch(node:, filter: nil)
38
+ dataset = @filters.apply(dataset: @database[node.source.table].select(*select_columns(node:)), source: node.source)
39
+ dataset = dataset.where(filter) if filter
40
+ return per_parent(node:, dataset:) if filter && node.rows
41
+
42
+ @orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: [node.source.table]).all
43
+ end
44
+
45
+ # Keeps each parent's rows, e.g. each user's 3 most viewed posts, by numbering the rows within each parent.
46
+ def per_parent(node:, dataset:)
47
+ numbered = dataset.select_append(row_number(node:).as(ROW_NUMBER))
48
+ @database.from(numbered).where(ROW_NUMBER => row_numbers(rows: node.rows)).order(ROW_NUMBER)
49
+ .all.map { |row| row.except(ROW_NUMBER) }
50
+ end
51
+
52
+ def row_number(node:)
53
+ table = node.source.table
54
+ order = @orders.order(keys: node.keys, tables: [table])
55
+ Sequel.function(:row_number).over(partition: Sequel[table][node.link.child_column], order: order.empty? ? nil : order)
56
+ end
57
+
58
+ # Row numbers start at 1, and rows count from 0.
59
+ def row_numbers(rows:)
60
+ @orders.check_rows!(rows:)
61
+ first = rows.offset + 1
62
+ rows.limit ? first..(rows.offset + rows.limit) : (first..)
63
+ end
64
+
65
+ # The columns asked for, plus the columns each link needs.
66
+ def select_columns(node:)
67
+ return [Sequel[node.source.table].*] if node.source.columns.empty?
68
+
69
+ link_columns = [node.link&.child_column, *node.children.map { |child| child.link.parent_column }].compact
70
+ (node.source.columns + link_columns).uniq
71
+ end
72
+
73
+ # Turns fetched rows into output hashes: the columns asked for, plus each child nested under its key.
74
+ def shape(node:, rows:)
75
+ nested = node.children.to_h { |child| [child, nested_rows(child:, rows:)] }
76
+
77
+ rows.map do |row|
78
+ output = node.source.columns.empty? ? row.dup : row.slice(*node.source.columns)
79
+ node.children.each { |child| output[child.key] = matches(child:, row:, nested: nested[child]) }
80
+ output
81
+ end
82
+ end
83
+
84
+ def matches(child:, row:, nested:)
85
+ matches = nested.fetch(row[child.link.parent_column], [])
86
+ child.link.many ? matches : matches.first
87
+ end
88
+
89
+ # Fetches a child's rows for all parent rows in one query, grouped by the value that links them.
90
+ def nested_rows(child:, rows:)
91
+ values = rows.map { |row| row[child.link.parent_column] }.compact.uniq
92
+ return {} if values.empty?
93
+
94
+ child_rows = fetch(node: child, filter: { child.link.child_column => values })
95
+ child_rows.zip(shape(node: child, rows: child_rows))
96
+ .group_by { |row, _output| row[child.link.child_column] }
97
+ .transform_values { |pairs| pairs.map(&:last) }
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Builds the tree Nesting loads from an expression. Tables in brackets nest directly under their table, and the
5
+ # operands of a + chain nest by following foreign keys from the first operand.
6
+ class NestingTree
7
+ MULTIPLE_PATHS_MESSAGE = 'Foreign keys link %<tables>s by more than one path, so + has no single way to nest them. ' \
8
+ 'Nest them in brackets instead, e.g. Users[:username, Posts[:title, Likes[]]]'
9
+
10
+ def initialize(schema:)
11
+ @schema = schema
12
+ end
13
+
14
+ # Builds the tree for an expression: a table with any tables nested in its brackets, or a + chain.
15
+ def build(node:)
16
+ raise JoinError, QueryCompiler::INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
17
+ return build(node: node.node).with(keys: node.keys, rows: node.rows) if node.is_a?(DataExpression::Ordered)
18
+ return bracketed(source: node) if node.is_a?(DataExpression::Source)
19
+
20
+ chain(operands: operands(node:).map { |operand| build(node: operand) })
21
+ end
22
+
23
+ # Returns the nested key and Link for a child table. On the "one" side a key matching a foreign key column picks it,
24
+ # so `author:` follows `author_id`. FilterCompiler links related tables the same way.
25
+ def link_to(parent:, child:, key:)
26
+ to_one = @schema.links(from: parent, to: child)
27
+ named = named_link(links: to_one, key:)
28
+ return one(parent:, child:, link: named, key:) if named
29
+
30
+ links = to_one + @schema.links(from: child, to: parent)
31
+ raise JoinError, "No foreign key links #{child} to #{parent}" if links.empty?
32
+ raise JoinError, many_keys_message(parent:, child:, to_one:) if links.length > 1
33
+ return one(parent:, child:, link: links.first, key:) if to_one.any?
34
+
35
+ many(parent:, child:, link: links.first, key:)
36
+ end
37
+
38
+ private
39
+
40
+ # A + chain is left deep, and each operand is a table, a bracketed table or a parenthesised + chain.
41
+ def operands(node:)
42
+ return [node] unless node.is_a?(DataExpression::Join)
43
+ raise JoinError, QueryCompiler::NESTING_JOIN_MESSAGE unless node.type == :nest
44
+
45
+ operands(node: node.left) + [node.right]
46
+ end
47
+
48
+ # Tables in brackets nest directly under the table whose brackets they're in.
49
+ def bracketed(source:)
50
+ children = source.nested.map do |nested|
51
+ child = build(node: nested.node)
52
+ key, link = link_to(parent: source.table, child: child.source.table, key: nested.key)
53
+ child.with(key:, link:)
54
+ end
55
+
56
+ with_children(node: Nesting::Node.new(source:, key: nil, link: nil, children: []), children:)
57
+ end
58
+
59
+ # Links the operands into a tree from the first. Every operand must be linked, and only by one path.
60
+ def chain(operands:)
61
+ check_duplicates!(operands:)
62
+
63
+ edges = operands.combination(2).select { |operand, other| linked?(operand.source.table, other.source.table) }
64
+ visited = [operands.first]
65
+ root = attach(operand: operands.first, node: operands.first, edges:, visited:)
66
+ check_chain!(operands:, visited:, edges:)
67
+
68
+ root
69
+ end
70
+
71
+ # Nests each unvisited operand linked to `operand`. `node` is the operand with its key and link set.
72
+ def attach(operand:, node:, edges:, visited:)
73
+ neighbours = edges.filter_map { |pair| (pair - [operand]).first if pair.include?(operand) } - visited
74
+ visited.concat(neighbours)
75
+
76
+ children = neighbours.map do |child|
77
+ key, link = link_to(parent: node.source.table, child: child.source.table, key: nil)
78
+ attach(operand: child, node: child.with(key:, link:), edges:, visited:)
79
+ end
80
+
81
+ with_children(node:, children:)
82
+ end
83
+
84
+ def with_children(node:, children:)
85
+ children = node.children + children
86
+ check_keys!(table: node.source.table, children:)
87
+ node.with(children:)
88
+ end
89
+
90
+ def check_duplicates!(operands:)
91
+ tables = operands.map { |operand| operand.source.table }
92
+ duplicate = tables.find { |table| tables.count(table) > 1 }
93
+ raise JoinError, "Can't join #{duplicate} to itself. Name each in brackets, e.g. author: Users[:username]" if duplicate
94
+ end
95
+
96
+ def check_chain!(operands:, visited:, edges:)
97
+ tables = operands.map { |operand| operand.source.table }
98
+ unreached = (operands - visited).map { |operand| operand.source.table }
99
+ raise JoinError, "No foreign key links #{unreached.join(' and ')} to #{(tables - unreached).join(', ')}" if unreached.any?
100
+ raise JoinError, format(MULTIPLE_PATHS_MESSAGE, tables: tables.join(', ')) if edges.length > operands.length - 1
101
+ end
102
+
103
+ def check_keys!(table:, children:)
104
+ keys = children.map(&:key)
105
+ duplicate = keys.find { |key| keys.count(key) > 1 }
106
+ raise JoinError, "Two tables nest in #{table} as #{duplicate}. Name them, e.g. author: Users[:username]" if duplicate
107
+ end
108
+
109
+ def linked?(table, other)
110
+ @schema.links(from: table, to: other).any? || @schema.links(from: other, to: table).any?
111
+ end
112
+
113
+ def named_link(links:, key:)
114
+ named = links.select { |link| key && [key, :"#{key}_id"].include?(link[:columns].first) }
115
+ named.first if named.one?
116
+ end
117
+
118
+ # `user_id` nests as `user:`, like the association name in most ORMs.
119
+ def one(parent:, child:, link:, key:)
120
+ column, foreign_key = sole_columns(parent:, child:, link:)
121
+ key ||= column.to_s.end_with?('_id') ? column.to_s.delete_suffix('_id').to_sym : child
122
+ [key, Nesting::Link.new(parent_column: column, child_column: foreign_key, many: false)]
123
+ end
124
+
125
+ def many(parent:, child:, link:, key:)
126
+ column, foreign_key = sole_columns(parent:, child:, link:)
127
+ [key || child, Nesting::Link.new(parent_column: foreign_key, child_column: column, many: true)]
128
+ end
129
+
130
+ def sole_columns(parent:, child:, link:)
131
+ columns, keys = link.values_at(:columns, :keys)
132
+ raise JoinError, "Nesting doesn't support composite foreign keys between #{parent} and #{child}" if columns.length > 1
133
+
134
+ [columns.first, keys.first]
135
+ end
136
+
137
+ def many_keys_message(parent:, child:, to_one:)
138
+ message = "More than one foreign key links #{parent} and #{child}"
139
+ return message if to_one.empty?
140
+
141
+ names = to_one.map { |link| "#{link[:columns].first.to_s.delete_suffix('_id')}: #{LowData.constant_name(child)}[]" }
142
+ "#{message}. Name the one to follow, e.g. #{names.sort.join(' or ')}"
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Compiles sort keys into ORDER BY expressions and rows into LIMIT and OFFSET. Each column is qualified with the one
5
+ # table that has it, so `Users[:username] & Posts[:title] ^ Col[:views]` sorts by posts.views.
6
+ class OrderCompiler
7
+ UNPICKED_PAGE_MESSAGE = 'Pick a page after splitting rows into pages, e.g. (Posts[:title] / 20)[0]'
8
+
9
+ def initialize(schema:)
10
+ @schema = schema
11
+ end
12
+
13
+ def apply(dataset:, keys:, rows:, tables:)
14
+ dataset = dataset.order(*order(keys:, tables:)) if keys.any?
15
+ return dataset unless rows
16
+
17
+ check_rows!(rows:)
18
+ dataset.limit(rows.limit, rows.offset.zero? ? nil : rows.offset)
19
+ end
20
+
21
+ def order(keys:, tables:)
22
+ keys.map do |key|
23
+ column = Sequel[owner(column: key.column, tables:)][key.column]
24
+ key.descending ? column.desc : column
25
+ end
26
+ end
27
+
28
+ def check_rows!(rows:)
29
+ raise ArgumentError, UNPICKED_PAGE_MESSAGE if rows.page_size
30
+ end
31
+
32
+ private
33
+
34
+ def owner(column:, tables:)
35
+ owners = tables.select { |table| @schema.columns(table:).include?(column) }
36
+ raise JoinError, "No table in #{tables.join(', ')} has a #{column} column to sort by" if owners.empty?
37
+ raise JoinError, "#{owners.join(' and ')} both have a #{column} column, so Col[:#{column}] is ambiguous" if owners.length > 1
38
+
39
+ owners.first
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Compiles a DataExpression join tree into a Sequel dataset, inferring each join's ON clause from foreign keys.
5
+ class QueryCompiler
6
+ INVERSION_JOIN_MESSAGE = "Table[] can't be joined with other expressions"
7
+ NESTING_JOIN_MESSAGE = 'Nested tables return nested data, so + and tables nested in brackets can\'t be combined with &, <, > or <=>'
8
+ ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users[] < Posts[]) ^ Col[:username]. Ruby binds ^ tighter ' \
9
+ 'than <, > and <=>, and * and / tighter than every join, so add parentheses around the join'
10
+
11
+ def initialize(database:)
12
+ raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
13
+
14
+ @database = database
15
+ @schema = Schema.new(database:)
16
+ @filters = FilterCompiler.new(database:, schema: @schema)
17
+ @orders = OrderCompiler.new(schema: @schema)
18
+ end
19
+
20
+ def compile(node:)
21
+ return compile_ordered(node:) if node.is_a?(DataExpression::Ordered)
22
+
23
+ compile_unordered(node:, sources: [])
24
+ end
25
+
26
+ private
27
+
28
+ # Sort keys and rows apply to the whole query, so each sort column can belong to any joined table.
29
+ def compile_ordered(node:)
30
+ sources = []
31
+ dataset = compile_unordered(node: node.node, sources:)
32
+ @orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: sources.map(&:table))
33
+ end
34
+
35
+ def compile_unordered(node:, sources:)
36
+ return compile_inversion(node:, sources:) if node.is_a?(DataExpression::Inversion)
37
+
38
+ dataset = filter(dataset: build(node:, sources:), sources:)
39
+ dataset.select(*sources.flat_map { |source| selections(source:) })
40
+ end
41
+
42
+ def compile_inversion(node:, sources:)
43
+ tree, selections = Inversion.new(schema: @schema).resolve(node:)
44
+ filter(dataset: build(node: tree, sources:), sources:).select(*selections)
45
+ end
46
+
47
+ def build(node:, sources:)
48
+ raise JoinError, INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
49
+ raise JoinError, ORDERED_JOIN_MESSAGE if node.is_a?(DataExpression::Ordered)
50
+ return source_dataset(source: node, sources:) if node.is_a?(DataExpression::Source)
51
+ raise JoinError, NESTING_JOIN_MESSAGE if nesting?(node:) || nesting?(node: node.right)
52
+
53
+ raise JoinError, nested_join_message if node.right.is_a?(DataExpression::Join)
54
+
55
+ join_dataset(dataset: build(node: node.left, sources:), type: node.type, right: node.right, sources:)
56
+ end
57
+
58
+ def join_dataset(dataset:, type:, right:, sources:)
59
+ raise JoinError, INVERSION_JOIN_MESSAGE if right.is_a?(DataExpression::Inversion)
60
+ raise JoinError, ORDERED_JOIN_MESSAGE if right.is_a?(DataExpression::Ordered)
61
+ raise JoinError, NESTING_JOIN_MESSAGE if right.nested.any?
62
+ raise JoinError, "Can't join #{right.table} to itself" if sources.any? { |source| source.table == right.table }
63
+
64
+ conditions = join_conditions(left_tables: sources.map(&:table), right_table: right.table)
65
+ sources << right
66
+ dataset.join_table(type, right.table, conditions)
67
+ end
68
+
69
+ def nesting?(node:)
70
+ node.is_a?(DataExpression::Join) && node.type == :nest
71
+ end
72
+
73
+ def source_dataset(source:, sources:)
74
+ raise JoinError, NESTING_JOIN_MESSAGE if source.nested.any?
75
+
76
+ sources << source
77
+ @database[source.table]
78
+ end
79
+
80
+ # Every table's filters go in the WHERE clause, so they apply to the joined rows.
81
+ def filter(dataset:, sources:)
82
+ sources.reduce(dataset) { |filtered, source| @filters.apply(dataset: filtered, source:) }
83
+ end
84
+
85
+ def selections(source:)
86
+ return [Sequel[source.table].*] if source.columns.empty?
87
+
88
+ source.columns.map { |column| Sequel[source.table][column] }
89
+ end
90
+
91
+ def join_conditions(left_tables:, right_table:)
92
+ candidates = left_tables.flat_map do |left_table|
93
+ @schema.references(from: right_table, to: left_table) + @schema.references(from: left_table, to: right_table)
94
+ end
95
+
96
+ raise JoinError, "No foreign key links #{right_table} to #{left_tables.join(', ')}" if candidates.empty?
97
+ raise JoinError, "More than one foreign key links #{right_table} to #{left_tables.join(', ')}" if candidates.length > 1
98
+
99
+ candidates.first
100
+ end
101
+
102
+ def nested_join_message
103
+ 'Ruby evaluated a join on the right first, e.g. `Users[] < Posts[] & Comments[]` runs `Posts[] & Comments[]` first ' \
104
+ 'because & binds tighter than <. Add parentheses to join from the left: `(Users[] < Posts[]) & Comments[]`'
105
+ end
106
+ end
107
+ end
data/lib/rows.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Which sorted rows to keep. Rows count from 0, as in Ruby:
5
+ # Posts[] * 10 # LIMIT 10
6
+ # (Posts[] / 20)[2] # LIMIT 20 OFFSET 40, the third page of 20
7
+ # Posts[] ^ Col[:id][20...40] # LIMIT 20 OFFSET 20
8
+ # Pages have a `page_size` until a page is picked, and a `limit` and `offset` after.
9
+ Rows = ::Data.define(:offset, :limit, :page_size) do
10
+ def self.limit(count)
11
+ new(offset: 0, limit: positive!(count:, operator: '*'), page_size: nil)
12
+ end
13
+
14
+ def self.pages(size)
15
+ new(offset: 0, limit: nil, page_size: positive!(count: size, operator: '/'))
16
+ end
17
+
18
+ def self.range(range)
19
+ check_range!(range:)
20
+ first = range.begin || 0
21
+ return new(offset: first, limit: nil, page_size: nil) if range.end.nil?
22
+
23
+ new(offset: first, limit: [range.end - first + (range.exclude_end? ? 0 : 1), 0].max, page_size: nil)
24
+ end
25
+
26
+ # One limit, range or page size per expression.
27
+ def self.merge(rows, other)
28
+ raise ArgumentError, 'An expression takes one limit, range or page size, not two' if rows && other
29
+
30
+ rows || other
31
+ end
32
+
33
+ def self.check_range!(range:)
34
+ unless range.is_a?(Range) && [range.begin, range.end].all? { |bound| bound.nil? || bound.is_a?(Integer) }
35
+ raise ArgumentError, "Pick rows with a range of integers, e.g. Col[:views][20...40], not #{range.inspect}"
36
+ end
37
+ raise ArgumentError, "Rows count from 0, so #{range.inspect} can't start below 0" if range.begin&.negative?
38
+ end
39
+
40
+ def self.positive!(count:, operator:)
41
+ return count if count.is_a?(Integer) && count.positive?
42
+
43
+ raise ArgumentError, "#{operator} takes a positive integer, e.g. Posts[:title] #{operator} 10, not #{count.inspect}"
44
+ end
45
+
46
+ def page(number)
47
+ raise ArgumentError, 'Split rows into pages before picking one, e.g. (Posts[:title] / 20)[0]' unless page_size
48
+ unless number.is_a?(Integer) && !number.negative?
49
+ raise ArgumentError, "Pages count from 0, so pick one with an integer from 0, not #{number.inspect}"
50
+ end
51
+
52
+ with(offset: number * page_size, limit: page_size, page_size: nil)
53
+ end
54
+ end
55
+ end
data/lib/schema.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Reads tables, columns and foreign keys from the database, caching each lookup.
5
+ class Schema
6
+ def initialize(database:)
7
+ @database = database
8
+ @schemas = {}
9
+ @foreign_keys = {}
10
+ end
11
+
12
+ def columns(table:)
13
+ schema(table:).map(&:first)
14
+ end
15
+
16
+ def primary_key(table:)
17
+ schema(table:).select { |_column, info| info[:primary_key] }.map(&:first)
18
+ end
19
+
20
+ def foreign_keys(table:)
21
+ @foreign_keys[table] ||= @database.foreign_key_list(table)
22
+ end
23
+
24
+ # Tables with a foreign key to or from the table.
25
+ def related_tables(table:)
26
+ outgoing = foreign_keys(table:).map { |foreign_key| foreign_key[:table] }
27
+ incoming = @database.tables.select do |other|
28
+ foreign_keys(table: other).any? { |foreign_key| foreign_key[:table] == table }
29
+ end
30
+
31
+ (outgoing + incoming).uniq - [table]
32
+ end
33
+
34
+ # Foreign keys on the `from` table that reference the `to` table, as join conditions.
35
+ def references(from:, to:)
36
+ links(from:, to:).map do |link|
37
+ link[:columns].zip(link[:keys]).to_h { |column, key| [Sequel[from][column], Sequel[to][key]] }
38
+ end
39
+ end
40
+
41
+ # Foreign keys on the `from` table that reference the `to` table, as their columns and the keys they reference.
42
+ def links(from:, to:)
43
+ foreign_keys(table: from).select { |foreign_key| foreign_key[:table] == to }.map do |foreign_key|
44
+ { columns: foreign_key[:columns], keys: foreign_key[:key] || primary_key(table: to) }
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def schema(table:)
51
+ @schemas[table] ||= @database.schema(table)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # A table named by a constant, such as `Users`, waiting for its columns: `Users[:username]`.
5
+ # An inverted reference is `Table`, standing in for the repository's table and any table related to it.
6
+ # A reference can carry filters from round brackets, `Posts(published: true)`, and be negated with `~Posts`.
7
+ class TableReference
8
+ FILTER_KEYWORD_MESSAGE = 'Filters go in round brackets, e.g. Posts(published: true)[:title], not %<keywords>s'
9
+
10
+ attr_reader :table, :filter, :negated
11
+
12
+ def initialize(table:, inverted: false, filter: DataExpression::Filter.new, negated: false)
13
+ @table = table
14
+ @inverted = inverted
15
+ @filter = filter
16
+ @negated = negated
17
+ end
18
+
19
+ # 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.
22
+ # Reviews[:rating, author: Users[:username]] # A keyword with a data expression names a nested table.
23
+ def [](*arguments, **keywords)
24
+ raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
25
+ return inverted(arguments:, keywords:) if @inverted
26
+
27
+ expressions, columns = arguments.partition { |argument| argument.is_a?(DataExpression) }
28
+ check_columns!(columns:)
29
+ nested = nested(pairs: expressions.map { |expression| [nil, expression] } + named(keywords:))
30
+
31
+ DataExpression.new(node: DataExpression::Source.new(table:, columns:, nested:, filter:))
32
+ end
33
+
34
+ # Each argument's type decides its meaning:
35
+ # Posts(published: true) # A keyword with a value filters by that value.
36
+ # Posts { views > 100 } # A block is a Sequel virtual row condition.
37
+ # Users(Posts), Users(~Posts) # A table keeps rows with, or without, related rows.
38
+ # Users(Posts => 3..) # A table as a key keeps rows with that many related rows.
39
+ # Reviews(author: Users(username: 'alice')) # A keyword with a table names the foreign key to follow.
40
+ def where(*arguments, **keywords, &block)
41
+ related = arguments.map { |argument| related(key: nil, reference: argument) }
42
+ values = {}
43
+
44
+ keywords.each do |key, value|
45
+ if key.is_a?(TableReference) then related << related(key: nil, reference: key, count: value)
46
+ elsif value.is_a?(TableReference) then related << related(key:, reference: value)
47
+ else values[key] = value
48
+ end
49
+ end
50
+
51
+ conditions = block ? [Sequel.virtual_row(&block)] : []
52
+ with(filter: filter.merge(DataExpression::Filter.new(values:, conditions:, related:)))
53
+ end
54
+
55
+ # `~Posts` keeps rows without related posts.
56
+ def ~
57
+ with(negated: !negated)
58
+ end
59
+
60
+ def source
61
+ DataExpression::Source.new(table:, columns: [], filter:)
62
+ end
63
+
64
+ private
65
+
66
+ def with(filter: @filter, negated: @negated)
67
+ self.class.new(table:, inverted: @inverted, filter:, negated:)
68
+ end
69
+
70
+ def related(key:, reference:, count: nil)
71
+ unless reference.is_a?(TableReference)
72
+ raise ArgumentError, "Round brackets take filters and tables, e.g. Posts(published: true) or Users(Posts), not #{reference.inspect}"
73
+ end
74
+ raise ArgumentError, "Write ~#{LowData.constant_name(reference.table)} or a count, not both" if reference.negated && count
75
+
76
+ DataExpression::Related.new(key:, source: reference.source, count: count || (reference.negated ? 0 : 1..))
77
+ end
78
+
79
+ def named(keywords:)
80
+ named, filters = keywords.partition { |_key, value| value.is_a?(DataExpression) }
81
+ raise ArgumentError, format(FILTER_KEYWORD_MESSAGE, keywords: filters.to_h) if filters.any?
82
+
83
+ named
84
+ end
85
+
86
+ def nested(pairs:)
87
+ pairs.map { |key, expression| DataExpression::Nested.new(key:, node: expression.node) }
88
+ end
89
+
90
+ def inverted(arguments:, keywords:)
91
+ check_columns!(columns: arguments)
92
+ raise ArgumentError, 'Table[] takes column names only' if keywords.any?
93
+
94
+ DataExpression.new(node: DataExpression::Inversion.new(table:, columns: arguments, filter:))
95
+ end
96
+
97
+ def check_columns!(columns:)
98
+ invalid = columns.reject { |column| column.is_a?(Symbol) }
99
+ raise ArgumentError, "Columns are symbols, e.g. Posts[:title], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
100
+ end
101
+ end
102
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ VERSION = '0.1.0'
5
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lowdata
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - maedi
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: expressions
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lowtype
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.6'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sequel
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ description: The repository pattern with data expressions + the usual query building
55
+ email:
56
+ - maediprichard@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/col.rb
62
+ - lib/data_expression.rb
63
+ - lib/errors.rb
64
+ - lib/filter_compiler.rb
65
+ - lib/inversion.rb
66
+ - lib/lowdata.rb
67
+ - lib/nesting.rb
68
+ - lib/nesting_tree.rb
69
+ - lib/order_compiler.rb
70
+ - lib/query_compiler.rb
71
+ - lib/rows.rb
72
+ - lib/schema.rb
73
+ - lib/table_reference.rb
74
+ - lib/version.rb
75
+ homepage: https://github.com/low-rb/lowdata
76
+ licenses: []
77
+ metadata:
78
+ homepage_uri: https://github.com/low-rb/lowdata
79
+ source_code_uri: https://github.com/low-rb/lowdata/src/branch/main
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.3.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 4.0.6
95
+ specification_version: 4
96
+ summary: The repository pattern with a twist; Data Expressions
97
+ test_files: []