bespoke 0.2.0 → 0.2.1

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,16 @@
1
+ module Bespoke
2
+ class Export
3
+ class Filter
4
+ class StringLiteral
5
+ def self.match?(string)
6
+ string.start_with?("'") && string.end_with?("'")
7
+ end
8
+
9
+ def self.extract_from(string)
10
+ unquoted_literal = string[1..-2]
11
+ Sequel.expr(unquoted_literal)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -11,7 +11,12 @@ module Bespoke
11
11
 
12
12
  def database
13
13
  return @database if @database
14
- @database = Sequel.sqlite
14
+ @database = Sequel.sqlite(nil, after_connect: ->(connection) {
15
+ connection.create_function('concat_ws', -1) {|proxy, *args|
16
+ separator = args.pop
17
+ proxy.result = args.compact.join(separator)
18
+ }
19
+ })
15
20
  create_tables
16
21
  @database
17
22
  end
@@ -27,9 +32,15 @@ module Bespoke
27
32
  end
28
33
  end
29
34
 
30
- def query(sql)
35
+ def [](sql)
31
36
  database[sql]
32
37
  end
38
+ alias_method :query, :[]
39
+
40
+ def projection_named(table_name)
41
+ table_symbol = database.tables.find{|t| t.to_s == table_name.to_s}
42
+ table_symbol.nil? ? nil : database[table_symbol]
43
+ end
33
44
 
34
45
  protected
35
46
 
@@ -39,17 +50,13 @@ module Bespoke
39
50
 
40
51
  def create_table(prj)
41
52
  database.drop_table?(prj.name)
53
+ prj.ensure_primary_key!
42
54
  database.create_table(prj.name) do
43
55
  prj.fields.each do |field|
44
- if field.name.to_s == "ref_id"
45
- column :ref_id, :string, :primary_key => true
46
- else
47
- column field.name, field.type
48
- end
56
+ column field.name, field.type, field.column_options
49
57
  end
50
58
  end
51
59
  end
52
-
53
60
  end
54
61
  end
55
62
 
@@ -0,0 +1,17 @@
1
+ require 'mustache'
2
+
3
+ if RUBY_VERSION >= '2.0'
4
+ class Mustache
5
+ TABLE_FOR_ESCAPE_HTML = {
6
+ '&' => '&',
7
+ '"' => '"',
8
+ '<' => '&lt;',
9
+ '>' => '&gt;',
10
+ }
11
+ REGEXP_FOR_ESCAPE_HTML = Regexp.new("[#{ TABLE_FOR_ESCAPE_HTML.keys.map{|k| Regexp.escape(k) }.join }]")
12
+
13
+ def escapeHTML(string)
14
+ string.gsub(REGEXP_FOR_ESCAPE_HTML, TABLE_FOR_ESCAPE_HTML)
15
+ end
16
+ end
17
+ end
@@ -1,28 +1,20 @@
1
- module Bespoke
2
- class TypedField
3
- attr_reader :name, :type, :xpath
4
-
5
- # @name The name of the column or field
6
- # @type The Sqlite3 type of the data
7
- # @xpath The xpath query that retrieves the data from XML
8
- def initialize(name, type, xpath)
9
- @name = name
10
- @type = type
11
- @xpath = xpath
12
- end
13
- end
1
+ require_relative 'typed_field'
14
2
 
3
+ module Bespoke
15
4
  class Projection
16
5
  attr_reader :name, :fields, :xpath_root
17
6
 
18
- def initialize(name, xpath_root=nil)
7
+ class NoPrimaryKeyError < Bespoke::Error; end
8
+
9
+ def initialize(name, xpath_root=nil, &block)
19
10
  @name = name.to_s
20
11
  @xpath_root = xpath_root
21
12
  @fields = []
13
+ handle_init_block(&block) if block_given?
22
14
  end
23
15
 
24
- def field(name, type=:string, xpath=nil)
25
- @fields << TypedField.new(name, type, xpath)
16
+ def field(name, type=:string, options_or_xpath = {})
17
+ @fields << TypedField.new(name, type, options_or_xpath)
26
18
  end
27
19
 
28
20
  def xpath_hash
@@ -31,5 +23,27 @@ module Bespoke
31
23
  end.flatten(1)
32
24
  Hash[*pairs]
33
25
  end
26
+
27
+ def ensure_primary_key!
28
+ return if @fields.any?(&:primary_key?)
29
+
30
+ ref_id = @fields.find{|f| f.name == :ref_id }
31
+ if ref_id
32
+ warn 'Implicit primary key of ref_id detected, this feature is deprecated and will be removed soon.'
33
+ ref_id.make_primary_key!
34
+ else
35
+ raise NoPrimaryKeyError, "No primary key specified or implied for projection #{ self.name }"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def handle_init_block(&block)
42
+ if block.arity == 1
43
+ block.call(self)
44
+ else
45
+ instance_exec(&block)
46
+ end
47
+ end
34
48
  end
35
- end
49
+ end
@@ -1,4 +1,4 @@
1
- require 'mustache'
1
+ require_relative './mustache'
2
2
  require 'tempfile'
3
3
 
4
4
  module Bespoke
@@ -42,4 +42,4 @@ module Bespoke
42
42
  end
43
43
  end
44
44
  end
45
- end
45
+ end
@@ -0,0 +1,30 @@
1
+ module Bespoke
2
+ class TypedField
3
+ attr_reader :name, :type, :column_options, :xpath
4
+
5
+ # @name The name of the column or field
6
+ # @type The Sqlite3 type of the data
7
+ # @options_or_xpath The options to be supplied to Sequel when generating the column
8
+ # along with the xpath used to extract the data from XML
9
+ def initialize(name, type, options_or_xpath = {})
10
+ @name = name
11
+ @type = type
12
+ if options_or_xpath.is_a? Hash
13
+ @xpath = options_or_xpath.delete(:xpath)
14
+ @column_options = options_or_xpath
15
+ else
16
+ @xpath = options_or_xpath
17
+ @column_options = {}
18
+ warn "Passing a string or array as the xpath is deprecated, please supply an options hash with an xpath key at: #{ caller[1] }"
19
+ end
20
+ end
21
+
22
+ def make_primary_key!
23
+ column_options[:primary_key] = true
24
+ end
25
+
26
+ def primary_key?
27
+ !!column_options[:primary_key]
28
+ end
29
+ end
30
+ end
@@ -1,3 +1,3 @@
1
1
  module Bespoke
2
- VERSION = '0.2.0' unless defined?(Bespoke::VERSION)
2
+ VERSION = '0.2.1' unless defined?(Bespoke::VERSION)
3
3
  end
@@ -98,4 +98,4 @@
98
98
  </xsl:choose>
99
99
  </xsl:template>
100
100
 
101
- </xsl:stylesheet>
101
+ </xsl:stylesheet>
@@ -82,4 +82,4 @@
82
82
  </xsl:choose>
83
83
  </xsl:template>
84
84
 
85
- </xsl:stylesheet>
85
+ </xsl:stylesheet>
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ module Bespoke
4
+ class Export::Filter
5
+ describe FunctionCall do
6
+ describe '#function' do
7
+ it 'must correctly identify the function specified when no args are present' do
8
+ call = FunctionCall.new('rand()')
9
+ expect(call.function).to eq 'rand'
10
+ end
11
+
12
+ it 'must correctly identify the function specified when an argument is being passed' do
13
+ call = FunctionCall.new("foo_bar(0)")
14
+ expect(call.function).to eq 'foo_bar'
15
+ end
16
+
17
+ it 'must correctly identify the function specified when multiple arguments are being supplied' do
18
+ call = FunctionCall.new("foo_bar(0, 2, foo)")
19
+ expect(call.function).to eq 'foo_bar'
20
+ end
21
+ end
22
+
23
+ describe '#arguments' do
24
+ it 'must return an empty array when no arguments are specified for the function being called' do
25
+ call = FunctionCall.new('rand()')
26
+ expect(call.arguments).to eq []
27
+ end
28
+
29
+ it 'must return an array with a single string when a single argument is supplied' do
30
+ call = FunctionCall.new("foo_bar(0)")
31
+ expect(call.arguments).to eq ['0']
32
+ end
33
+
34
+ it 'must return an array with the specified values when multiple values are supplied' do
35
+ call = FunctionCall.new("foo_bar(0, foo, bar)")
36
+ expect(call.arguments).to eq %w{0 foo bar}
37
+ end
38
+
39
+ it 'must reutrn the correct values when the whitespace is inconsistent' do
40
+ call = FunctionCall.new("foo_bar(\t 0, foo,bar )")
41
+ expect(call.arguments).to eq %w{0 foo bar}
42
+ end
43
+ end
44
+
45
+ describe '#extract' do
46
+ it 'must return a Sequel function node for most inputs' do
47
+ call = FunctionCall.new("foo_bar(0, foo, bar)")
48
+ extracted_call = call.extract
49
+ expect(extracted_call).to be_a Sequel::SQL::Function
50
+ expect(extracted_call.name).to eq 'FOO_BAR'
51
+ expect(extracted_call.args).to eq %w{0 foo bar}
52
+ end
53
+
54
+ it 'must specially handle a function named random_str' do
55
+ call = FunctionCall.new("random_str(4)")
56
+ extracted_call = call.extract
57
+ expect(extracted_call).to be_a Sequel::LiteralString
58
+ expect(extracted_call.to_s).to eq 'LOWER(HEX(RANDOMBLOB(4)))'
59
+ end
60
+ end
61
+
62
+ describe "custom functions" do
63
+ it "can be created" do
64
+ call = FunctionCall.new("myfn('hi')", :myfn => ->(args) { "LOWER(#{args.first})" })
65
+ extracted_call = call.extract
66
+ expect(extracted_call).to be_a Sequel::LiteralString
67
+ expect(extracted_call.to_s).to eq "LOWER('hi')"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,62 @@
1
+ require 'spec_helper'
2
+ require 'bespoke/export/filter/identifier'
3
+
4
+ module Bespoke
5
+ class Export::Filter
6
+ describe Identifier do
7
+ describe '#sequel_identifier' do
8
+ it 'must return an unqualified identifer with the right content for an unqualified column' do
9
+ identifier = Identifier.new('id')
10
+ sequel_identifier = identifier.sequel_identifier
11
+ expect(sequel_identifier).to be_a Sequel::SQL::Identifier
12
+ expect(sequel_identifier.value).to eq 'id'
13
+ end
14
+
15
+ it 'must return a qualified identifier with the right conent for a qualified column' do
16
+ identifier = Identifier.new('students.id')
17
+ sequel_identifier = identifier.sequel_identifier
18
+ expect(sequel_identifier).to be_a Sequel::SQL::QualifiedIdentifier
19
+ expect(sequel_identifier.table).to eq 'students'
20
+ expect(sequel_identifier.column).to eq 'id'
21
+ end
22
+ end
23
+
24
+ describe '#parts' do
25
+ it 'must correctly handle identifiers surrounded by arbitrary whitespace' do
26
+ identifier = Identifier.new("\t students.id ")
27
+ expect(identifier.parts).to eq({table: 'students', column: 'id'})
28
+ end
29
+
30
+ it 'must take an unqualified unquoted column identifier and return the correct values' do
31
+ identifier = Identifier.new('id')
32
+ expect(identifier.parts).to eq({table: nil, column: 'id'})
33
+ end
34
+
35
+ it 'must take a qualified unquoted column identifier and return the correct values' do
36
+ identifier = Identifier.new('students.id')
37
+ expect(identifier.parts).to eq({table: 'students', column: 'id'})
38
+ end
39
+
40
+ it 'must take an unqualified quoted column identifier and return the correct values' do
41
+ identifier = Identifier.new('"name.first_name"')
42
+ expect(identifier.parts).to eq({table: nil, column: 'name.first_name'})
43
+ end
44
+
45
+ it 'must take a qualified column identifier with the column name quoted and return the correct values' do
46
+ identifier = Identifier.new('students."name.first_name"')
47
+ expect(identifier.parts).to eq({table: 'students', column: 'name.first_name'})
48
+ end
49
+
50
+ it 'must take a qualified column identifier with the table name quoted and return the correct values' do
51
+ identifier = Identifier.new('"students".name')
52
+ expect(identifier.parts).to eq({table: 'students', column: 'name'})
53
+ end
54
+
55
+ it 'must take a qualified, fully quoted column identifier and return the correct values' do
56
+ identifier = Identifier.new('"students"."name.first_name"')
57
+ expect(identifier.parts).to eq({table: 'students', column: 'name.first_name'})
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,102 @@
1
+ require 'spec_helper'
2
+
3
+ module Bespoke
4
+ class Export::Filter
5
+ describe SelectTemplate do
6
+ let(:join) { basic_join }
7
+ let(:db) { join.database }
8
+ let(:table) { db[:two] }
9
+ let(:template) { SelectTemplate.new(:course_id, '') }
10
+
11
+ describe '#compile' do
12
+ let(:result) { template.compile }
13
+ let(:sql) { table.select(result).sql }
14
+
15
+ it 'must build an aliased expression with the correct alias' do
16
+ expect(result.aliaz).to eq :course_id
17
+ end
18
+
19
+ it 'must interpret a symbol as a column specification' do
20
+ template.template_string = :id
21
+ expect(sql).to include %q[`id` AS 'course_id']
22
+ end
23
+
24
+ it 'must interpret a symbol with double underscores as a qualified column specification' do
25
+ template.template_string = :sections__id
26
+ expect(sql).to include %q[`sections`.`id` AS 'course_id']
27
+ end
28
+
29
+ it 'must interpret unqualified templates correctly' do
30
+ template.template_string = '<id>'
31
+ expect(sql).to include %q[`id` AS 'course_id']
32
+ end
33
+
34
+ it 'must interpret qualified templates correctly' do
35
+ template.template_string = '<sections.id>'
36
+ expect(sql).to include %q[`sections`.`id` AS 'course_id']
37
+ end
38
+
39
+ it 'must interpret a template with multiple values as neeing to be caalesced' do
40
+ template.template_string = '<id | foo>'
41
+ expect(sql).to include %q[COALESCE(`id`, `foo`) AS 'course_id']
42
+ end
43
+
44
+ it 'must interepret a template in single quotes as a literal value' do
45
+ template.template_string = "<'foo'>"
46
+ expect(sql).to include %q['foo' AS 'course_id']
47
+ end
48
+
49
+ it 'must coalesce a identifer and a literal string correctly' do
50
+ template.template_string = "<id | 'foo'>"
51
+ expect(sql).to include %q[COALESCE(`id`, 'foo') AS 'course_id']
52
+ end
53
+
54
+ it 'must interepret a default value of random_str(\d+) correctly' do
55
+ template.template_string = '<courses.enrollment_count | random_str(6)>'
56
+ expect(sql).to include %q[COALESCE(`courses`.`enrollment_count`, LOWER(HEX(RANDOMBLOB(6)))) AS 'course_id']
57
+ end
58
+
59
+ it 'must interpret a template consisting of a column and a prepended string correctly' do
60
+ template.template_string = 'course_<courses.id>'
61
+ expect(sql).to include %q[('course_' || `courses`.`id`) AS 'course_id']
62
+ end
63
+
64
+ it 'must interpret a template consisting of a columna and an appended string correctly' do
65
+ template.template_string = '<students.student_username>@foo.k12.mo.us'
66
+ expect(sql).to include %q[(`students`.`student_username` || '@foo.k12.mo.us') AS 'course_id']
67
+ end
68
+
69
+ it 'must interpret complex strings with column templates interpolated inside them' do
70
+ template.template_string = 'course_<courses.id>_record'
71
+ expect(sql).to include %q[('course_' || `courses`.`id` || '_record') AS 'course_id']
72
+ end
73
+
74
+ it 'must interpret 2 element joined array selecte template strings correctly' do
75
+ template.template_string = '<section_id>-<term_id>'
76
+ expect(sql).to include %q[concat_ws(`section_id`, `term_id`, '-') AS 'course_id']
77
+ end
78
+
79
+ it 'must interpret joined array select template strings correctly' do
80
+ template.template_string = '<section_id>-<term_id>-<staff_id>'
81
+ expect(sql).to include %q[concat_ws(`section_id`, `term_id`, `staff_id`, '-') AS 'course_id']
82
+ end
83
+
84
+ it 'must extract the correct separator from joined array template strings' do
85
+ template.template_string = '<section_id>=+=<term_id>=+=<staff_id>'
86
+ expect(sql).to include %q[concat_ws(`section_id`, `term_id`, `staff_id`, '=+=') AS 'course_id']
87
+ end
88
+
89
+ context "with custom function" do
90
+ let(:template) {
91
+ SelectTemplate.new(:course_id, '', :myfn => ->(args) { "MYFN()" })
92
+ }
93
+
94
+ it 'does it' do
95
+ template.template_string = '<myfn(5)>'
96
+ expect(sql).to include %q[MYFN()]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end