polyglot_sql_ffi 0.1.0-aarch64-linux-gnu

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: 61e5ac76e197d3843bb8e8d44e6d588ca33b31e83b878dd3cd1a6f1ddd8b45a7
4
+ data.tar.gz: 4ebff0650298fd2f8d2c8cd2b73d94e1f1a2c426a6e3c73d3826421994c3ba95
5
+ SHA512:
6
+ metadata.gz: 55a9e3864f3cbaa59ed35ad9d0bb11c0db6749c5c811f5cb60e22d7cd2a8d7a44978c62617053cad1870ffd895dfd1844239c9c7de7e1d3a9a35efa00f742e48
7
+ data.tar.gz: a169c32f8af62575b3499bec91ec2d626c3d2cda55a792d64d1b5f9ad0342c87232e8ae03f952bd299a47d2a53a624774da6219ca3b11ffda0717e259f1d6185
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem "rspec", "~> 3.12"
9
+ gem "rake", "~> 13.0"
10
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TobiLG <github@tobilg.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # polyglot_sql_ffi
2
+
3
+ Ruby wrapper for [polyglot](https://github.com/tobilg/polyglot) — a SQL parser, transpiler,
4
+ optimizer, and lineage engine written in Rust. Mirrors the polyglot-sql Python package's
5
+ module-level API via FFI.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "polyglot_sql_ffi"
11
+ ```
12
+
13
+ The library itself is namespaced `PolyglotSql` and required as `polyglot_sql` (both happen
14
+ automatically under bundler).
15
+
16
+ Platform gems bundle the native library (linux/macos, x86_64/arm64). Installs that bypass platform
17
+ gems — JRuby, bundler's `force_ruby_platform`, a lockfile missing your platform — get the source gem
18
+ instead, which downloads the same official binary at install time. Platforms polyglot publishes no
19
+ binary for (e.g. Alpine/musl) must build `libpolyglot_sql_ffi` themselves and point
20
+ `POLYGLOT_SQL_FFI_PATH` at it.
21
+
22
+ ## Usage
23
+
24
+ ```ruby
25
+ require "polyglot_sql"
26
+
27
+ # Transpile between dialects
28
+ PolyglotSql.transpile("SELECT IFNULL(a, b) FROM t", read: :mysql, write: :postgres)
29
+ # => "SELECT COALESCE(a, b) FROM t"
30
+
31
+ # Parse to a Hash AST and generate back
32
+ ast = PolyglotSql.parse_one("SELECT 1 + 2", dialect: :sqlite)
33
+ PolyglotSql.generate(ast, dialect: :sqlite)
34
+
35
+ # Pretty-format
36
+ PolyglotSql.format("SELECT a,b FROM t WHERE x=1", dialect: :sqlite)
37
+
38
+ # Validate
39
+ PolyglotSql.validate("SELECT a FROM t", dialect: :sqlite).valid?
40
+
41
+ # Canonicalize / optimize
42
+ PolyglotSql.optimize("WITH cte AS (SELECT a FROM t) SELECT a FROM cte", dialect: :sqlite)
43
+ # => "SELECT a FROM t"
44
+
45
+ # Lineage
46
+ PolyglotSql.lineage("total", sql, dialect: :sqlite) # by column name
47
+ PolyglotSql.lineage_at(0, sql, dialect: :sqlite) # by output ordinal
48
+ PolyglotSql.output_columns(sql, dialect: :sqlite) # ordered output shape
49
+ PolyglotSql.source_tables("total", sql, dialect: :sqlite)
50
+ ```
51
+
52
+ Set a default dialect once instead of passing it everywhere:
53
+
54
+ ```ruby
55
+ PolyglotSql.configure { |c| c.default_dialect = :sqlite }
56
+ ```
57
+
58
+ Errors raise subclasses of `PolyglotSql::Error` (`ParseError`, `TranspileError`,
59
+ `ColumnResolutionError`, ...).
60
+
61
+ ## Native library
62
+
63
+ The gem loads `libpolyglot_sql_ffi` from `lib/polyglot_sql/`; set `POLYGLOT_SQL_FFI_PATH` to
64
+ override with your own build. The bundled version is pinned by `PolyglotSql::POLYGLOT_VERSION`.
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ bundle install
70
+ rake native:fetch
71
+ bundle exec rspec
72
+ ```
73
+
74
+ ## Releasing
75
+
76
+ Tag `vX.Y.Z` and push. The release workflow packages platform gems plus the source gem and publishes
77
+ to RubyGems via trusted publishing.
78
+
79
+ ## License
80
+
81
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ namespace :native do
8
+ desc "Fetch the prebuilt polyglot-sql-ffi library for this platform"
9
+ task :fetch do
10
+ ruby "scripts/fetch_native.rb"
11
+ end
12
+ end
13
+
14
+ task default: :spec
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # extconf.rb — invoked by `gem install` / `bundle install` to provide the
4
+ # polyglot-sql-ffi shared library, downloaded from the official polyglot
5
+ # GitHub release and checksum-verified.
6
+
7
+ require "fileutils"
8
+ require "open-uri"
9
+ require "digest"
10
+
11
+ RUST_REPO = "https://github.com/tobilg/polyglot"
12
+ VERSION = File.read(File.expand_path("../../lib/polyglot_sql/version.rb", __dir__))[/POLYGLOT_VERSION = "(.+?)"/, 1]
13
+ RUST_TAG = "v#{VERSION}"
14
+ EXT_DIR = __dir__
15
+ LIB_DIR = File.expand_path("../../lib/polyglot_sql", EXT_DIR)
16
+
17
+ # Release artifact name for this platform, nil if none is published.
18
+ def release_artifact
19
+ cpu = RbConfig::CONFIG["host_cpu"]
20
+ arch = { "x86_64" => "x86_64", "arm64" => "aarch64", "aarch64" => "aarch64" }[cpu]
21
+ return nil unless arch
22
+
23
+ case RbConfig::CONFIG["host_os"]
24
+ when /linux/ then "polyglot-sql-ffi-linux-#{arch}.tar.gz"
25
+ when /darwin/ then "polyglot-sql-ffi-macos-#{arch}.tar.gz"
26
+ end
27
+ end
28
+
29
+ def install_prebuilt
30
+ artifact = release_artifact
31
+ unless artifact
32
+ abort <<~MSG
33
+ ERROR: no prebuilt polyglot-sql-ffi binary for this platform
34
+ (#{RbConfig::CONFIG["host_os"]} / #{RbConfig::CONFIG["host_cpu"]}).
35
+
36
+ Build libpolyglot_sql_ffi from #{RUST_REPO} (tag #{RUST_TAG}) with
37
+ `cargo build -p polyglot-sql-ffi --profile ffi_release`, then set
38
+ POLYGLOT_SQL_FFI_PATH to the resulting library and reinstall.
39
+ MSG
40
+ end
41
+
42
+ base = "#{RUST_REPO}/releases/download/#{RUST_TAG}"
43
+ puts "Downloading #{base}/#{artifact}..."
44
+ tarball = URI.open("#{base}/#{artifact}").read
45
+ checksums = URI.open("#{base}/checksums.sha256").read
46
+
47
+ expected = checksums[/^(\h{64})\s+.*#{Regexp.escape(artifact)}$/, 1]
48
+ abort "ERROR: checksum mismatch for #{artifact}" unless Digest::SHA256.hexdigest(tarball) == expected
49
+
50
+ dir = File.join(EXT_DIR, "prebuilt")
51
+ FileUtils.mkdir_p(dir)
52
+ File.binwrite(File.join(dir, artifact), tarball)
53
+ system("tar", "xzf", artifact, chdir: dir) or abort "ERROR: tar extraction failed"
54
+
55
+ so_file = Dir[File.join(dir, "**", "libpolyglot_sql_ffi.{so,dylib}")].first
56
+ abort "ERROR: shared library not found in #{artifact}" unless so_file
57
+
58
+ FileUtils.mkdir_p(LIB_DIR)
59
+ FileUtils.cp(so_file, LIB_DIR, verbose: true)
60
+ end
61
+
62
+ if Dir[File.join(LIB_DIR, "libpolyglot_sql_ffi.{so,dylib}")].any?
63
+ puts "libpolyglot_sql_ffi already present in #{LIB_DIR}, skipping."
64
+ else
65
+ install_prebuilt
66
+ end
67
+
68
+ # Dummy Makefile — required by the rubygems extension protocol.
69
+ File.write(File.join(EXT_DIR, "Makefile"), "all:\ninstall:\nclean:\n")
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolyglotSql
4
+ # Normalizes dialect arguments (Symbol or String) into the string names the
5
+ # native library understands. The authoritative list comes from
6
+ # {PolyglotSql.dialects}; unknown names are rejected by the native layer.
7
+ module Dialect
8
+ DEFAULT = "generic"
9
+
10
+ # Convenience aliases accepted in addition to the canonical names.
11
+ ALIASES = {
12
+ "postgresql" => "postgres",
13
+ "pg" => "postgres",
14
+ "mssql" => "tsql",
15
+ "sqlserver" => "tsql",
16
+ "mariadb" => "mysql",
17
+ }.freeze
18
+
19
+ # @param name [Symbol, String, nil]
20
+ # @return [String] canonical dialect name (default: "generic")
21
+ def self.resolve(name)
22
+ return DEFAULT if name.nil?
23
+
24
+ key = name.to_s.strip.downcase
25
+ return DEFAULT if key.empty?
26
+
27
+ ALIASES.fetch(key, key)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolyglotSql
4
+ # Base class for every error raised by this gem. Mirrors the Python
5
+ # package's `PolyglotError` hierarchy.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when the SQL cannot be tokenized or parsed.
9
+ class ParseError < Error; end
10
+
11
+ # Raised when SQL cannot be generated from an AST.
12
+ class GenerateError < Error; end
13
+
14
+ # Raised when transpilation between dialects fails.
15
+ class TranspileError < Error; end
16
+
17
+ # Raised when a lineage column cannot be resolved (not found,
18
+ # indeterminate, or ambiguous).
19
+ class ColumnResolutionError < TranspileError; end
20
+
21
+ # Raised when semantic/syntactic validation fails at the boundary
22
+ # (not the per-statement diagnostics returned by {PolyglotSql.validate}).
23
+ class ValidationError < Error; end
24
+
25
+ # Raised for invalid arguments: NULL/blank input, unknown dialect, bad UTF-8.
26
+ class ArgumentError < Error; end
27
+
28
+ # Raised when the native library cannot be found or loaded.
29
+ class LibraryNotFoundError < Error; end
30
+
31
+ # Status codes returned across the FFI boundary (see polyglot-sql-ffi types.rs).
32
+ module Status
33
+ SUCCESS = 0
34
+ PARSE_ERROR = 1
35
+ GENERATE_ERROR = 2
36
+ TRANSPILE_ERROR = 3
37
+ VALIDATION_ERROR = 4
38
+ INVALID_ARGUMENT = 5
39
+ SERIALIZATION_ERROR = 6
40
+ COLUMN_NOT_FOUND = 7
41
+ COLUMN_INDETERMINATE = 8
42
+ COLUMN_AMBIGUOUS = 9
43
+ INTERNAL_ERROR = 99
44
+
45
+ CLASS_FOR = {
46
+ PARSE_ERROR => ParseError,
47
+ GENERATE_ERROR => GenerateError,
48
+ TRANSPILE_ERROR => TranspileError,
49
+ VALIDATION_ERROR => ValidationError,
50
+ INVALID_ARGUMENT => ArgumentError,
51
+ SERIALIZATION_ERROR => Error,
52
+ COLUMN_NOT_FOUND => ColumnResolutionError,
53
+ COLUMN_INDETERMINATE => ColumnResolutionError,
54
+ COLUMN_AMBIGUOUS => ColumnResolutionError,
55
+ INTERNAL_ERROR => Error,
56
+ }.freeze
57
+
58
+ def self.error_class(status)
59
+ CLASS_FOR.fetch(status, Error)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require "json"
5
+
6
+ module PolyglotSql
7
+ # Low-level FFI bindings to libpolyglot_sql_ffi.
8
+ #
9
+ # Not intended for direct use -- see the public API on {PolyglotSql}.
10
+ # Every FFI function returns a +PolyglotResult+ (or +PolyglotValidationResult+)
11
+ # struct by value; the safe wrappers here read the payload, free the struct's
12
+ # owned strings, and raise on a non-zero status so callers never touch raw
13
+ # pointers or manage memory.
14
+ #
15
+ # @api private
16
+ module Native
17
+ extend FFI::Library
18
+
19
+ LIB_NAME = "polyglot_sql_ffi"
20
+ SOEXT = FFI::Platform::LIBSUFFIX # "so" on Linux, "dylib" on macOS
21
+
22
+ # Search order:
23
+ # 1. POLYGLOT_SQL_FFI_PATH (explicit override, matches the Go SDK)
24
+ # 2. lib/polyglot_sql/ inside the gem (where extconf.rb / fetch_native.rb put it)
25
+ # 3. system library path
26
+ def self.find_library
27
+ if (env_path = ENV["POLYGLOT_SQL_FFI_PATH"])
28
+ return env_path if File.exist?(env_path)
29
+ end
30
+
31
+ bundled = File.expand_path("lib#{LIB_NAME}.#{SOEXT}", __dir__)
32
+ return bundled if File.exist?(bundled)
33
+
34
+ LIB_NAME
35
+ end
36
+
37
+ begin
38
+ ffi_lib find_library
39
+ rescue LoadError => e
40
+ raise PolyglotSql::LibraryNotFoundError,
41
+ "Could not load libpolyglot_sql_ffi. #{e.message}\n\n" \
42
+ "Fetch the native library with:\n" \
43
+ " rake native:fetch\n\n" \
44
+ "Or set POLYGLOT_SQL_FFI_PATH to the full path of the .so/.dylib file."
45
+ end
46
+
47
+ # ── Result structs (returned by value; see polyglot-sql-ffi types.rs) ──
48
+
49
+ class Result < FFI::Struct
50
+ layout :data, :pointer, :error, :pointer, :status, :int32
51
+ end
52
+
53
+ class ValidationResult < FFI::Struct
54
+ layout :valid, :int32,
55
+ :errors_json, :pointer,
56
+ :error, :pointer,
57
+ :status, :int32
58
+ end
59
+
60
+ # ── C function declarations ────────────────────────────────────────
61
+
62
+ attach_function :polyglot_transpile, %i[string string string], Result.by_value
63
+ attach_function :polyglot_transpile_with_options, %i[string string string string], Result.by_value
64
+ attach_function :polyglot_parse, %i[string string], Result.by_value
65
+ attach_function :polyglot_parse_one, %i[string string], Result.by_value
66
+ attach_function :polyglot_parse_data_type, %i[string string], Result.by_value
67
+ attach_function :polyglot_tokenize, %i[string string], Result.by_value
68
+ attach_function :polyglot_generate, %i[string string], Result.by_value
69
+ attach_function :polyglot_generate_data_type, %i[string string], Result.by_value
70
+ attach_function :polyglot_format, %i[string string], Result.by_value
71
+ attach_function :polyglot_format_with_options, %i[string string string], Result.by_value
72
+ attach_function :polyglot_optimize, %i[string string], Result.by_value
73
+ attach_function :polyglot_lineage, %i[string string string], Result.by_value
74
+ attach_function :polyglot_lineage_with_schema, %i[string string string string], Result.by_value
75
+ attach_function :polyglot_lineage_at, %i[size_t string string], Result.by_value
76
+ attach_function :polyglot_lineage_at_with_schema, %i[size_t string string string], Result.by_value
77
+ attach_function :polyglot_output_columns, %i[string string], Result.by_value
78
+ attach_function :polyglot_output_columns_with_schema, %i[string string string], Result.by_value
79
+ attach_function :polyglot_source_tables, %i[string string string], Result.by_value
80
+ attach_function :polyglot_analyze_query, %i[string string], Result.by_value
81
+ attach_function :polyglot_openlineage_column_lineage, %i[string string], Result.by_value
82
+ attach_function :polyglot_openlineage_job_event, %i[string string], Result.by_value
83
+ attach_function :polyglot_openlineage_run_event, %i[string string], Result.by_value
84
+ attach_function :polyglot_diff, %i[string string string], Result.by_value
85
+ attach_function :polyglot_qualify_tables, %i[string string], Result.by_value
86
+ attach_function :polyglot_set_limit, %i[string uint64], Result.by_value
87
+ attach_function :polyglot_set_offset, %i[string uint64], Result.by_value
88
+ attach_function :polyglot_set_order_by, %i[string string], Result.by_value
89
+ attach_function :polyglot_rename_tables_with_options, %i[string string string], Result.by_value
90
+ attach_function :polyglot_annotate_types, %i[string string string], Result.by_value
91
+
92
+ attach_function :polyglot_validate, %i[string string], ValidationResult.by_value
93
+ attach_function :polyglot_validate_with_options, %i[string string string], ValidationResult.by_value
94
+
95
+ attach_function :polyglot_dialect_list, [], :pointer
96
+ attach_function :polyglot_dialect_count, [], :int32
97
+ # Returns a static pointer -- :string lets FFI read it without freeing.
98
+ attach_function :polyglot_version, [], :string
99
+
100
+ attach_function :polyglot_free_string, [:pointer], :void
101
+ attach_function :polyglot_free_result, [Result.by_value], :void
102
+ attach_function :polyglot_free_validation_result, [ValidationResult.by_value], :void
103
+
104
+ # ── Safe wrappers ──────────────────────────────────────────────────
105
+
106
+ # Read the payload from a Result struct, free its owned strings, and return
107
+ # the data. Raises the mapped error class on a non-zero status.
108
+ #
109
+ # @param result [Result]
110
+ # @return [String] the +data+ payload
111
+ def self.unwrap(result)
112
+ status = result[:status]
113
+ data = pointer_to_string(result[:data])
114
+ error = pointer_to_string(result[:error])
115
+
116
+ return data if status == Status::SUCCESS
117
+
118
+ raise Status.error_class(status),
119
+ error || "polyglot-sql failed with status #{status}"
120
+ ensure
121
+ polyglot_free_result(result)
122
+ end
123
+
124
+ # Read a ValidationResult into a plain Hash and free its owned strings.
125
+ #
126
+ # @param result [ValidationResult]
127
+ # @return [Hash] { valid: Boolean, errors: Array<Hash> }
128
+ def self.unwrap_validation(result)
129
+ status = result[:status]
130
+ error = pointer_to_string(result[:error])
131
+
132
+ # status 4 (VALIDATION_ERROR) is not a failure here: it means the SQL is
133
+ # invalid, and errors_json carries the diagnostics.
134
+ if status != Status::SUCCESS && status != Status::VALIDATION_ERROR
135
+ raise Status.error_class(status),
136
+ error || "polyglot-sql validation failed with status #{status}"
137
+ end
138
+
139
+ errors_json = pointer_to_string(result[:errors_json])
140
+ {
141
+ valid: result[:valid] == 1,
142
+ errors: errors_json ? JSON.parse(errors_json, max_nesting: false) : [],
143
+ }
144
+ ensure
145
+ polyglot_free_validation_result(result)
146
+ end
147
+
148
+ # Read, then free, a bare +char*+ returned by e.g. polyglot_dialect_list.
149
+ #
150
+ # @param ptr [FFI::Pointer]
151
+ # @return [String, nil]
152
+ def self.take_string(ptr)
153
+ return nil if ptr.null?
154
+
155
+ ptr.read_string.force_encoding("UTF-8")
156
+ ensure
157
+ polyglot_free_string(ptr) unless ptr.null?
158
+ end
159
+
160
+ def self.pointer_to_string(ptr)
161
+ return nil if ptr.null?
162
+
163
+ ptr.read_string.force_encoding("UTF-8")
164
+ end
165
+ private_class_method :pointer_to_string
166
+ end
167
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolyglotSql
4
+ # Wires a `config.polyglot_sql.default_dialect` setting into Rails so the
5
+ # dialect can be omitted from calls.
6
+ class Railtie < Rails::Railtie
7
+ config.polyglot_sql = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "polyglot_sql.configure" do |app|
10
+ PolyglotSql.configure do |c|
11
+ c.default_dialect = app.config.polyglot_sql.default_dialect
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolyglotSql
4
+ # Result of {PolyglotSql.validate}. Mirrors the Python package's
5
+ # +ValidationResult+: a validity flag plus a list of diagnostic Hashes
6
+ # (each with +message+, +line+, +col+/+column+, +code+, +severity+).
7
+ class ValidationResult
8
+ # @return [Array<Hash>]
9
+ attr_reader :errors
10
+
11
+ def initialize(valid, errors)
12
+ @valid = valid
13
+ @errors = errors
14
+ end
15
+
16
+ # @return [Boolean]
17
+ def valid?
18
+ @valid
19
+ end
20
+
21
+ def to_h
22
+ { valid: @valid, errors: @errors }
23
+ end
24
+
25
+ def inspect
26
+ "#<PolyglotSql::ValidationResult valid=#{@valid} errors=#{@errors.size}>"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolyglotSql
4
+ VERSION = "0.1.0"
5
+
6
+ # Upstream polyglot-sql-ffi crate version this gem is built against.
7
+ POLYGLOT_VERSION = "0.8.1"
8
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "polyglot_sql/version"
4
+ require_relative "polyglot_sql/error"
5
+ require_relative "polyglot_sql/dialect"
6
+ require_relative "polyglot_sql/validation_result"
7
+ require_relative "polyglot_sql/native"
8
+
9
+ require_relative "polyglot_sql/railtie" if defined?(Rails::Railtie)
10
+
11
+ # PolyglotSql wraps the polyglot-sql-ffi native library, mirroring the
12
+ # polyglot-sql Python package: parse, transpile across 30+ dialects, generate,
13
+ # pretty-format, validate, optimize, lineage, and query analysis.
14
+ #
15
+ # The parsed AST is a plain Ruby Hash/Array (deserialized from the engine's
16
+ # JSON), and can be round-tripped back through {.generate}.
17
+ #
18
+ # @example Transpile between dialects
19
+ # PolyglotSql.transpile("SELECT `a` FROM `t`", read: :mysql, write: :sqlite)
20
+ # # => "SELECT \"a\" FROM \"t\""
21
+ #
22
+ # @example Pretty-format SQLite
23
+ # PolyglotSql.format("SELECT a,b FROM t WHERE x=1", dialect: :sqlite)
24
+ module PolyglotSql
25
+ class Configuration
26
+ # Default dialect used when none is specified.
27
+ # @return [Symbol, String, nil]
28
+ attr_accessor :default_dialect
29
+
30
+ def initialize
31
+ @default_dialect = nil
32
+ end
33
+ end
34
+
35
+ class << self
36
+ # @return [Configuration]
37
+ def configuration
38
+ @configuration ||= Configuration.new
39
+ end
40
+
41
+ def configure
42
+ yield(configuration)
43
+ end
44
+
45
+ # ── Transpile ──────────────────────────────────────────────────────
46
+
47
+ # Transpile SQL from one dialect to another.
48
+ #
49
+ # @param sql [String]
50
+ # @param read [Symbol, String, nil] source dialect (alias: +from+)
51
+ # @param write [Symbol, String, nil] target dialect (alias: +to+)
52
+ # @param pretty [Boolean] format the output with indentation and newlines
53
+ # @param from [Symbol, String, nil] alias for +read+
54
+ # @param to [Symbol, String, nil] alias for +write+
55
+ # @return [String, Array<String>] one String, or an Array for multi-statement input
56
+ def transpile(sql, read: nil, write: nil, pretty: false, from: nil, to: nil)
57
+ read_str = resolve_dialect(read || from)
58
+ write_str = resolve_dialect(write || to)
59
+
60
+ json =
61
+ if pretty
62
+ Native.unwrap(Native.polyglot_transpile_with_options(
63
+ sql, read_str, write_str, encode(pretty: true)
64
+ ))
65
+ else
66
+ Native.unwrap(Native.polyglot_transpile(sql, read_str, write_str))
67
+ end
68
+
69
+ unwrap_list(decode(json))
70
+ end
71
+
72
+ # ── Parse / tokenize ───────────────────────────────────────────────
73
+
74
+ # Parse SQL into an Array of AST statements (each a Hash).
75
+ #
76
+ # @return [Array<Hash>]
77
+ def parse(sql, dialect: nil)
78
+ decode(Native.unwrap(Native.polyglot_parse(sql, resolve_dialect(dialect))))
79
+ end
80
+
81
+ # Parse SQL expected to contain exactly one statement.
82
+ #
83
+ # @return [Hash]
84
+ def parse_one(sql, dialect: nil)
85
+ decode(Native.unwrap(Native.polyglot_parse_one(sql, resolve_dialect(dialect))))
86
+ end
87
+
88
+ # Parse a SQL type expression (e.g. "DECIMAL(10, 2)") into a DataType AST.
89
+ #
90
+ # @return [Hash]
91
+ def parse_data_type(sql, dialect: nil)
92
+ decode(Native.unwrap(Native.polyglot_parse_data_type(sql, resolve_dialect(dialect))))
93
+ end
94
+
95
+ # Tokenize SQL into an Array of tokens.
96
+ #
97
+ # @return [Array<Hash>]
98
+ def tokenize(sql, dialect: nil)
99
+ decode(Native.unwrap(Native.polyglot_tokenize(sql, resolve_dialect(dialect))))
100
+ end
101
+
102
+ # ── Generate ───────────────────────────────────────────────────────
103
+
104
+ # Generate SQL from an AST (a Hash from {.parse_one} or an Array from {.parse}).
105
+ #
106
+ # @param ast [Hash, Array<Hash>]
107
+ # @param pretty [Boolean] format the output with indentation and newlines
108
+ # @return [String, Array<String>] one String, or an Array for multi-statement AST
109
+ def generate(ast, dialect: nil, pretty: false)
110
+ dialect_str = resolve_dialect(dialect)
111
+ ast_json = encode(statements(ast))
112
+ sql = unwrap_list(decode(Native.unwrap(Native.polyglot_generate(ast_json, dialect_str))))
113
+ return sql unless pretty
114
+
115
+ # The FFI has no pretty-generate; re-format the generated SQL instead.
116
+ pretty_each(sql, dialect_str)
117
+ end
118
+
119
+ # Generate a SQL type string from a DataType AST.
120
+ #
121
+ # @return [String]
122
+ def generate_data_type(data_type, dialect: nil)
123
+ Native.unwrap(Native.polyglot_generate_data_type(encode(data_type), resolve_dialect(dialect)))
124
+ end
125
+
126
+ # ── Format (pretty-print) ──────────────────────────────────────────
127
+
128
+ # Pretty-format SQL, honoring the target dialect's quoting rules.
129
+ #
130
+ # @param max_input_bytes [Integer, nil] guard override
131
+ # @param max_tokens [Integer, nil] guard override
132
+ # @param max_ast_nodes [Integer, nil] guard override
133
+ # @param max_set_op_chain [Integer, nil] guard override
134
+ # @return [String, Array<String>]
135
+ def format(sql, dialect: nil, max_input_bytes: nil, max_tokens: nil,
136
+ max_ast_nodes: nil, max_set_op_chain: nil)
137
+ dialect_str = resolve_dialect(dialect)
138
+ options = compact(
139
+ maxInputBytes: max_input_bytes,
140
+ maxTokens: max_tokens,
141
+ maxAstNodes: max_ast_nodes,
142
+ maxSetOpChain: max_set_op_chain,
143
+ )
144
+
145
+ json =
146
+ if options.empty?
147
+ Native.unwrap(Native.polyglot_format(sql, dialect_str))
148
+ else
149
+ Native.unwrap(Native.polyglot_format_with_options(sql, dialect_str, encode(options)))
150
+ end
151
+
152
+ unwrap_list(decode(json))
153
+ end
154
+ alias_method :format_sql, :format
155
+
156
+ # ── Validate ───────────────────────────────────────────────────────
157
+
158
+ # Validate SQL syntactically and (optionally) semantically.
159
+ #
160
+ # @param strict_syntax [Boolean] reject compatibility forms (trailing commas, ...)
161
+ # @param semantic [Boolean] add W001-W004 semantic warnings
162
+ # @return [ValidationResult]
163
+ def validate(sql, dialect: nil, strict_syntax: false, semantic: false)
164
+ dialect_str = resolve_dialect(dialect)
165
+
166
+ result =
167
+ if strict_syntax || semantic
168
+ Native.unwrap_validation(Native.polyglot_validate_with_options(
169
+ sql, dialect_str,
170
+ encode(strictSyntax: strict_syntax, semantic: semantic)
171
+ ))
172
+ else
173
+ Native.unwrap_validation(Native.polyglot_validate(sql, dialect_str))
174
+ end
175
+
176
+ ValidationResult.new(result[:valid], result[:errors])
177
+ end
178
+
179
+ # ── Optimize ───────────────────────────────────────────────────────
180
+
181
+ # Run the full optimizer pipeline over the SQL.
182
+ #
183
+ # @return [String, Array<String>]
184
+ def optimize(sql, dialect: nil)
185
+ unwrap_list(decode(Native.unwrap(Native.polyglot_optimize(sql, resolve_dialect(dialect)))))
186
+ end
187
+
188
+ # ── Lineage / analysis ─────────────────────────────────────────────
189
+
190
+ # Column-level lineage for +column+ within +sql+.
191
+ #
192
+ # @param schema [Hash, nil] optional ValidationSchema for schema-aware lineage
193
+ # @return [Hash] a LineageNode
194
+ def lineage(column, sql, dialect: nil, schema: nil)
195
+ dialect_str = resolve_dialect(dialect)
196
+ json =
197
+ if schema
198
+ Native.unwrap(Native.polyglot_lineage_with_schema(column, sql, encode(schema), dialect_str))
199
+ else
200
+ Native.unwrap(Native.polyglot_lineage(column, sql, dialect_str))
201
+ end
202
+
203
+ decode(json)
204
+ end
205
+
206
+ # Column-level lineage for the zero-based output +ordinal+ within +sql+.
207
+ # Use instead of {lineage} when output names are duplicated or ambiguous.
208
+ #
209
+ # @param schema [Hash, nil] optional ValidationSchema for schema-aware lineage
210
+ # @return [Hash] a LineageNode
211
+ def lineage_at(ordinal, sql, dialect: nil, schema: nil)
212
+ dialect_str = resolve_dialect(dialect)
213
+ json =
214
+ if schema
215
+ Native.unwrap(Native.polyglot_lineage_at_with_schema(ordinal, sql, encode(schema), dialect_str))
216
+ else
217
+ Native.unwrap(Native.polyglot_lineage_at(ordinal, sql, dialect_str))
218
+ end
219
+
220
+ decode(json)
221
+ end
222
+
223
+ # Physical source tables feeding +column+.
224
+ #
225
+ # @return [Array<String>]
226
+ def source_tables(column, sql, dialect: nil)
227
+ decode(Native.unwrap(Native.polyglot_source_tables(column, sql, resolve_dialect(dialect))))
228
+ end
229
+
230
+ # Ordered output columns of +sql+. With +schema+, wildcards are expanded
231
+ # into concrete columns.
232
+ #
233
+ # @param schema [Hash, nil] optional ValidationSchema
234
+ # @return [Hash] columns with name/ordinal plus an +ordinalComplete+ flag
235
+ def output_columns(sql, dialect: nil, schema: nil)
236
+ dialect_str = resolve_dialect(dialect)
237
+ json =
238
+ if schema
239
+ Native.unwrap(Native.polyglot_output_columns_with_schema(sql, encode(schema), dialect_str))
240
+ else
241
+ Native.unwrap(Native.polyglot_output_columns(sql, dialect_str))
242
+ end
243
+
244
+ decode(json)
245
+ end
246
+
247
+ # Structural analysis of a query (relations, base tables, projections, CTEs).
248
+ #
249
+ # @param options [Hash, nil] AnalyzeQueryOptions (may include +dialect+ and +schema+)
250
+ # @return [Hash] a QueryAnalysis
251
+ def analyze_query(sql, options: nil, dialect: nil)
252
+ opts = options ? options.dup : {}
253
+ opts[:dialect] ||= resolve_dialect(dialect) if dialect
254
+ decode(Native.unwrap(Native.polyglot_analyze_query(sql, encode(opts))))
255
+ end
256
+
257
+ # ── OpenLineage ────────────────────────────────────────────────────
258
+
259
+ # @return [Hash] OpenLineageColumnLineageResult
260
+ def openlineage_column_lineage(sql, options)
261
+ decode(Native.unwrap(Native.polyglot_openlineage_column_lineage(sql, encode(options))))
262
+ end
263
+
264
+ # @return [Hash] OpenLineageEventResult
265
+ def openlineage_job_event(sql, options)
266
+ decode(Native.unwrap(Native.polyglot_openlineage_job_event(sql, encode(options))))
267
+ end
268
+
269
+ # @return [Hash] OpenLineageEventResult
270
+ def openlineage_run_event(sql, options)
271
+ decode(Native.unwrap(Native.polyglot_openlineage_run_event(sql, encode(options))))
272
+ end
273
+
274
+ # ── Diff ───────────────────────────────────────────────────────────
275
+
276
+ # Structural diff between two SQL statements.
277
+ #
278
+ # @return [Array<Hash>] diff edits
279
+ def diff(sql1, sql2, dialect: nil)
280
+ decode(Native.unwrap(Native.polyglot_diff(sql1, sql2, resolve_dialect(dialect))))
281
+ end
282
+
283
+ # ── AST mutators ───────────────────────────────────────────────────
284
+
285
+ # @return [Array<Hash>] the modified AST
286
+ def set_limit(ast, limit)
287
+ decode(Native.unwrap(Native.polyglot_set_limit(encode(statements(ast)), limit)))
288
+ end
289
+
290
+ # @return [Array<Hash>] the modified AST
291
+ def set_offset(ast, offset)
292
+ decode(Native.unwrap(Native.polyglot_set_offset(encode(statements(ast)), offset)))
293
+ end
294
+
295
+ # @param order_by [Hash, Array<Hash>] ORDER BY expression AST
296
+ # @return [Array<Hash>] the modified AST
297
+ def set_order_by(ast, order_by)
298
+ decode(Native.unwrap(Native.polyglot_set_order_by(encode(statements(ast)), encode(statements(order_by)))))
299
+ end
300
+
301
+ # @param options [Hash] qualify options (e.g. catalog/schema defaults)
302
+ # @return [Array<Hash>] the modified AST
303
+ def qualify_tables(ast, options: {})
304
+ decode(Native.unwrap(Native.polyglot_qualify_tables(encode(statements(ast)), encode(options))))
305
+ end
306
+
307
+ # @param mapping [Hash] old-name => new-name
308
+ # @param options [Hash] rename options
309
+ # @return [Array<Hash>] the modified AST
310
+ def rename_tables(ast, mapping, options: {})
311
+ decode(Native.unwrap(Native.polyglot_rename_tables_with_options(
312
+ encode(statements(ast)), encode(mapping), encode(options)
313
+ )))
314
+ end
315
+
316
+ # Annotate an AST with inferred column types.
317
+ #
318
+ # @param schema [Hash, nil] optional ValidationSchema
319
+ # @return [Hash]
320
+ def annotate_types(sql, dialect: nil, schema: nil)
321
+ schema_json = schema ? encode(schema) : nil
322
+ decode(Native.unwrap(Native.polyglot_annotate_types(sql, resolve_dialect(dialect), schema_json)))
323
+ end
324
+
325
+ # ── Introspection ──────────────────────────────────────────────────
326
+
327
+ # @return [Array<String>] the dialect names the engine supports
328
+ def dialects
329
+ decode(Native.take_string(Native.polyglot_dialect_list))
330
+ end
331
+
332
+ # @return [Integer]
333
+ def dialect_count
334
+ Native.polyglot_dialect_count
335
+ end
336
+
337
+ # @return [String] the underlying polyglot-sql engine version, e.g. "0.8.1"
338
+ def version
339
+ Native.polyglot_version
340
+ end
341
+
342
+ private
343
+
344
+ def resolve_dialect(name)
345
+ name = configuration.default_dialect if name.nil?
346
+ Dialect.resolve(name)
347
+ end
348
+
349
+ # The FFI expects a Vec<Expression> JSON array. A single statement from
350
+ # {.parse_one} is a Hash and must be wrapped without Array()'s Hash-to-pairs
351
+ # coercion; an Array from {.parse} passes through unchanged.
352
+ def statements(ast)
353
+ ast.is_a?(Array) ? ast : [ast]
354
+ end
355
+
356
+ # A JSON array of strings collapses to a bare String for single-statement
357
+ # results (smart unwrap); multi-statement results stay an Array.
358
+ def unwrap_list(list)
359
+ return list unless list.is_a?(Array)
360
+
361
+ list.length == 1 ? list.first : list
362
+ end
363
+
364
+ def pretty_each(sql, dialect_str)
365
+ if sql.is_a?(Array)
366
+ sql.map { |s| unwrap_list(decode(Native.unwrap(Native.polyglot_format(s, dialect_str)))) }
367
+ else
368
+ unwrap_list(decode(Native.unwrap(Native.polyglot_format(sql, dialect_str))))
369
+ end
370
+ end
371
+
372
+ def compact(hash)
373
+ hash.reject { |_, v| v.nil? }
374
+ end
375
+
376
+ # Real-world ASTs nest far deeper than Ruby's default max_nesting of 100,
377
+ # so both directions disable the limit.
378
+ def decode(json)
379
+ JSON.parse(json, max_nesting: false)
380
+ end
381
+
382
+ def encode(obj)
383
+ JSON.generate(obj, max_nesting: false)
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name; the library lives under PolyglotSql.
4
+ require "polyglot_sql"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/polyglot_sql/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "polyglot_sql_ffi"
7
+ spec.version = PolyglotSql::VERSION
8
+ spec.authors = ["Accountaim"]
9
+ spec.summary = "Ruby wrapper for polyglot-sql: a SQL parser, optimizer, and transpiler"
10
+ spec.description = <<~DESC
11
+ A Ruby gem that wraps the polyglot-sql-ffi native library, mirroring the
12
+ polyglot-sql Python package: SQL parsing, transpilation across 30+ dialects,
13
+ pretty-formatting, validation, optimization, lineage, and query analysis.
14
+ DESC
15
+ spec.homepage = "https://github.com/AccountAim/polyglot-sql-ruby"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2.0"
18
+
19
+ spec.files = Dir.chdir(__dir__) do
20
+ Dir["{lib,ext}/**/*", "Gemfile", "Rakefile", "polyglot_sql_ffi.gemspec", "README.md", "LICENSE"].reject do |file|
21
+ file.match?(%r{\Alib/polyglot_sql/libpolyglot_sql_ffi\.(so|dylib)\z})
22
+ end
23
+ end
24
+
25
+ spec.require_paths = ["lib"]
26
+ spec.extensions = ["ext/polyglot_sql_ffi/extconf.rb"]
27
+
28
+ spec.add_dependency "ffi", "~> 1.15"
29
+
30
+ spec.metadata = {
31
+ "source_code_uri" => "https://github.com/AccountAim/polyglot-sql-ruby",
32
+ "rubygems_mfa_required" => "true",
33
+ }
34
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: polyglot_sql_ffi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: aarch64-linux-gnu
6
+ authors:
7
+ - Accountaim
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ffi
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.15'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.15'
27
+ description: |
28
+ A Ruby gem that wraps the polyglot-sql-ffi native library, mirroring the
29
+ polyglot-sql Python package: SQL parsing, transpilation across 30+ dialects,
30
+ pretty-formatting, validation, optimization, lineage, and query analysis.
31
+ email:
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - Gemfile
37
+ - LICENSE
38
+ - README.md
39
+ - Rakefile
40
+ - ext/polyglot_sql_ffi/extconf.rb
41
+ - lib/polyglot_sql.rb
42
+ - lib/polyglot_sql/dialect.rb
43
+ - lib/polyglot_sql/error.rb
44
+ - lib/polyglot_sql/libpolyglot_sql_ffi.so
45
+ - lib/polyglot_sql/native.rb
46
+ - lib/polyglot_sql/railtie.rb
47
+ - lib/polyglot_sql/validation_result.rb
48
+ - lib/polyglot_sql/version.rb
49
+ - lib/polyglot_sql_ffi.rb
50
+ - polyglot_sql_ffi.gemspec
51
+ homepage: https://github.com/AccountAim/polyglot-sql-ruby
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ source_code_uri: https://github.com/AccountAim/polyglot-sql-ruby
56
+ rubygems_mfa_required: 'true'
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 3.2.0
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.5.22
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: 'Ruby wrapper for polyglot-sql: a SQL parser, optimizer, and transpiler'
76
+ test_files: []