Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save Database connection as data link, SQL Server data link support #11343

Merged
Merged
9 changes: 9 additions & 0 deletions app/gui/src/dashboard/data/__tests__/dataLinkSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const BASE_DATA_LINKS_ROOT = path.resolve(REPO_ROOT, 'test/Base_Tests/data/datal
const S3_DATA_LINKS_ROOT = path.resolve(REPO_ROOT, 'test/AWS_Tests/data/')
const TABLE_DATA_LINKS_ROOT = path.resolve(REPO_ROOT, 'test/Table_Tests/data/datalinks/')
const SNOWFLAKE_DATA_LINKS_ROOT = path.resolve(REPO_ROOT, 'test/Snowflake_Tests/data/datalinks/')
const SQLSERVER_DATA_LINKS_ROOT = path.resolve(REPO_ROOT, 'test/Microsoft_Tests/data/datalinks/')

v.test('correctly validates example HTTP .datalink files with the schema', () => {
const schemas = [
Expand Down Expand Up @@ -106,3 +107,11 @@ v.test('correctly validates example Snowflake .datalink files with the schema',
testSchema(json, schema)
}
})

v.test('correctly validates example SQLServer .datalink files with the schema', () => {
const schemas = ['sqlserver-db.datalink']
for (const schema of schemas) {
const json = loadDataLinkFile(path.resolve(SQLSERVER_DATA_LINKS_ROOT, schema))
testSchema(json, schema)
}
})
47 changes: 46 additions & 1 deletion app/gui/src/dashboard/data/datalinkSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
{ "$ref": "#/$defs/EnsoFileDataLink" },
{ "$ref": "#/$defs/HttpFetchDataLink" },
{ "$ref": "#/$defs/PostgresDataLink" },
{ "$ref": "#/$defs/SnowflakeDataLink" }
{ "$ref": "#/$defs/SnowflakeDataLink" },
{ "$ref": "#/$defs/SQLServerDataLink" }
],
"$comment": "The fields `type` and `libraryName` are required for all data link types, but we currently don't add a top-level `required` setting to the schema, because it was confusing the code that is generating the modal."
},
Expand Down Expand Up @@ -236,6 +237,50 @@
},
"required": ["type", "libraryName", "account", "database_name", "credentials"]
},
"SQLServerDataLink": {
"title": "SQL Server Database Connection",
"type": "object",
"properties": {
"type": {
"title": "Type",
"const": "SQLServer_Connection",
"type": "string"
},
"libraryName": { "const": "Standard.Microsoft" },
"host": {
"title": "Hostname",
"type": "string"
},
"port": {
"title": "Port",
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 1433
},
"database_name": {
"title": "Database Name",
"type": "string"
},
"credentials": {
"title": "Credentials",
"type": "object",
"properties": {
"username": {
"title": "Username",
"$ref": "#/$defs/SecureValue"
},
"password": {
"title": "Password",
"$ref": "#/$defs/SecureValue"
}
},
"required": ["username", "password"]
},
"table": { "title": "Table to access", "type": "string" }
},
"required": ["type", "libraryName", "host", "port", "database_name"]
},

"Format": {
"title": "Format",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,27 @@ disallow_links_in_move source target ~action =
if is_source_data_link && is_target_data_link then Error.throw (Illegal_Argument.Error "The `move_to` operation cannot be used with data links. If you want to move the link, use `Data_Link.move`.") else
if is_source_data_link || is_target_data_link then Error.throw (Illegal_Argument.Error "The `move_to` operation cannot be used with data links. Please `.read` the data link and then write the data to the destination using the appropriate method.") else
action

## PRIVATE
Takes a secure value (either a Text or Enso_Secret) and returns a secret representation of it.

If given an existing secret, it will be returned as-is.
However, if given a plain text, it will create a new secret in the provided directory.

Because it may be creating new secret, this should only be run within an enabled Output context.
store_as_secret base_location:Enso_File name_hint:Text secure_value:Text|Enso_Secret -> Enso_Secret = case secure_value of
existing_secret : Enso_Secret -> existing_secret
plain_text : Text ->
create_fresh_secret ix =
secret_name = name_hint + (if ix == 0 then "" else "-"+ix.to_text)
r = Enso_Secret.create secret_name plain_text base_location
r.catch Illegal_Argument error->
if error.message.contains "already exists" then create_fresh_secret ix+1 else r
create_fresh_secret 0

## PRIVATE
save_password_for_data_link data_link_location:Enso_File secure_value:Text|Enso_Secret name_hint:Text="password" -> Enso_Secret =
secret_location = data_link_location.parent.if_nothing (Error.throw (Illegal_State.Error "Trying to create a secret to store the Data Link password, but the provided data link location: "+data_link_location.to_text+" does not have a parent directory. This should not happen."))
location_name = if data_link_location.name.ends_with data_link_extension then data_link_location.name.drop (..Last data_link_extension.length) else data_link_location.name
secret_location.if_not_error <|
store_as_secret secret_location location_name+"-"+name_hint secure_value
4 changes: 4 additions & 0 deletions distribution/lib/Standard/Base/0.0.0-dev/src/Panic.enso
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import project.Any.Any
import project.Data.Array.Array
import project.Data.Text.Text
import project.Data.Vector.Vector
import project.Error.Error
import project.Meta
Expand Down Expand Up @@ -292,3 +293,6 @@ type Wrapped_Dataflow_Error
## PRIVATE
Throws the original error.
unwrap self = Error.throw self.payload

## PRIVATE
to_display_text self -> Text = "Wrapped_Dataflow_Error: "+self.payload.to_display_text
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from Standard.Base import all
import Standard.Base.Data.Numbers.Number_Parse_Error
import Standard.Base.Errors.Common.Type_Error
import Standard.Base.Errors.Illegal_Argument.Illegal_Argument
import Standard.Base.Errors.Illegal_State.Illegal_State

import project.Connection.Client_Certificate.Client_Certificate
import project.Connection.Connection_Options.Connection_Options
import project.Connection.Credentials.Credentials
import project.Connection.Postgres_Connection.Postgres_Connection
import project.Connection.SSL_Mode.SSL_Mode
import project.Internal.Data_Link_Setup.Data_Link_Setup
import project.Internal.Postgres.Pgpass
import project.Internal.Postgres.Postgres_Data_Link_Setup.Postgres_Data_Link_Setup

polyglot java import org.postgresql.Driver

Expand Down Expand Up @@ -47,8 +48,8 @@ type Postgres
connect self options (allow_data_links : Boolean = True) =
if Driver.isRegistered.not then Driver.register

data_link_setup = if allow_data_links then Postgres_Data_Link_Setup.Available self else
Postgres_Data_Link_Setup.Unavailable "Saving connections established through a Data Link is not allowed. Please copy the Data Link instead."
data_link_setup = if allow_data_links then Data_Link_Setup.Available (create_data_link_structure self) else
Data_Link_Setup.already_a_data_link
properties = options.merge self.jdbc_properties

## Cannot use default argument values as gets in an infinite loop if you do.
Expand Down Expand Up @@ -117,3 +118,15 @@ default_postgres_port =

## PRIVATE
default_postgres_database = Environment.get "PGDATABASE" "postgres"

## PRIVATE
private create_data_link_structure details:Postgres data_link_location:Enso_File -> JS_Object =
credentials_json = details.credentials.if_not_nothing <|
Data_Link_Setup.save_credentials_for_data_link data_link_location details.credentials
if (details.use_ssl != SSL_Mode.Prefer) || details.client_cert.is_nothing.not then Error.throw (Illegal_Argument.Error "Cannot save connection as Data Link: custom SSL settings are currently unsupported.") else
JS_Object.from_pairs <|
header = [["type", "Postgres_Connection"], ["libraryName", "Standard.Database"]]
connection_part = [["host", details.host], ["port", details.port], ["database_name", details.database]]
schema_part = if details.schema.not_empty then [["schema", details.schema]] else []
credential_part = if credentials_json.is_nothing.not then [["credentials", credentials_json]] else []
header + connection_part + schema_part + credential_part
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import project.Dialect
import project.Internal.Connection.Entity_Naming_Properties.Entity_Naming_Properties
import project.Internal.IR.Query.Query
import project.Internal.JDBC_Connection
import project.Internal.Postgres.Postgres_Data_Link_Setup.Postgres_Data_Link_Setup
import project.Internal.Data_Link_Setup.Data_Link_Setup
import project.Internal.SQL_Type_Reference.SQL_Type_Reference
import project.SQL_Query.SQL_Query
import project.SQL_Statement.SQL_Statement
Expand All @@ -32,7 +32,8 @@ type Postgres_Connection
- url: The URL to connect to.
- properties: A vector of properties for the connection.
- make_new: A function that returns a new connection.
create : Text -> Vector -> (Text -> Text -> Postgres_Connection) -> Postgres_Data_Link_Setup -> Postgres_Connection
- data_link_setup: The setup for saving the connection as a data link.
create : Text -> Vector -> (Text -> Text -> Postgres_Connection) -> Data_Link_Setup -> Postgres_Connection
create url properties make_new data_link_setup =
jdbc_connection = JDBC_Connection.create url properties
encoding = parse_postgres_encoding (get_encoding_name jdbc_connection)
Expand All @@ -52,7 +53,8 @@ type Postgres_Connection
Arguments:
- connection: the underlying connection.
- make_new: a function that returns a new connection.
private Value (connection:Connection) (make_new : Text -> Text -> Postgres_Connection) (data_link_setup : Postgres_Data_Link_Setup)
- data_link_setup: the setup for saving the connection as a data link.
private Value (connection:Connection) (make_new : Text -> Text -> Postgres_Connection) (data_link_setup : Data_Link_Setup)

## ICON close
Closes the connection releasing the underlying database resources
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -958,9 +958,8 @@ type DB_Table
t2.read
limit : Integer -> DB_Table
limit self max_rows:Integer=1000 =
Feature.Sample.if_supported_else_throw self.connection.dialect "limit" <|
new_ctx = self.context.set_limit max_rows
self.updated_context new_ctx
new_ctx = self.context.set_limit max_rows
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this check removed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was getting failures like:

        Reason: An unexpected dataflow error (limit not yet supported for DB backend. Use .read to download the data and use this feature.) has been matched (at X:\NBO\enso\test\Table_Tests\src\Database\Common\Audit_Spec.enso:71:13-49).
        at <enso> Feature.if_supported_else_throw<arg-2>(X:\NBO\enso\built-distribution\enso-engine-0.0.0-dev-windows-amd64\enso-0.0.0-dev\lib\Standard\Database\0.0.0-dev\src\Feature.enso:60:13-73)

when invoking DB_Table.read.

I'm surprised it was working at all in the first place, because our read by default relies on limit. It is actually an interesting thing to investigate.

But overall - as currently implemented - limit is an intrinsic part of the read operation, so it must be supported if we want proper read. Thus I don't think it should be hidden behind Sample flag. We could hide it if we change how read works, but I'm not convinced we should.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so I figured it out.

The tests that were currently being ran for SQLServer just never called into DB_Table.read directly... Most of the tests would do table.at "col" . to_vector. This calls read but with parameter ..All_Rows meaning the limit is skipped.

But if I just call read with default parameters, limit is needed to make it work. IMO this makes limit fundamental to the basic read capability. We can redo read to work differently if we want to, but for now I think it is OK to do this. Also implementing limit is not very complicated for new backends, so I think it should be fine-ish.

self.updated_context new_ctx

## ALIAS add column, expression, formula, new column, update column
GROUP Standard.Base.Values
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from Standard.Base import all
import Standard.Base.Enso_Cloud.Data_Link.Data_Link
import Standard.Base.Errors.File_Error.File_Error
import Standard.Base.Errors.Illegal_Argument.Illegal_Argument
import Standard.Base.Errors.Illegal_State.Illegal_State
import Standard.Base.Runtime.Context
from Standard.Base.Enso_Cloud.Data_Link_Helpers import data_link_extension, secure_value_to_json, save_password_for_data_link

import project.Connection.Credentials.Credentials

## PRIVATE
type Data_Link_Setup
## PRIVATE
Available create_data_link_structure:Enso_File->JS_Object

## PRIVATE
Unavailable cause:Text

## PRIVATE
Returns an unavailable setup with reason being the connection was alraedy a data link.
already_a_data_link -> Data_Link_Setup = Data_Link_Setup.Unavailable "Saving connections established through a Data Link is not allowed. Please copy the Data Link instead."

## PRIVATE
save_as_data_link self destination on_existing_file:Existing_File_Behavior = case self of
Data_Link_Setup.Available create_fn -> Context.Output.if_enabled disabled_message="As writing is disabled, cannot save to a Data Link. Press the Write button ▶ to perform the operation." panic=False <|
case destination of
_ : Enso_File ->
replace_existing = case on_existing_file of
Existing_File_Behavior.Overwrite -> True
Existing_File_Behavior.Error -> False
_ -> Error.throw (Illegal_Argument.Error "Invalid value for `on_existing_file` parameter, only `Overwrite` and `Error` are supported here.")
exists_checked = if replace_existing.not && destination.exists then Error.throw (File_Error.Already_Exists destination)
exists_checked.if_not_error <|
json = create_fn destination
Data_Link.write_config destination json replace_existing
_ -> Error.throw (Illegal_Argument.Error "Currently a connection can only be saved as a Data Link into the Enso Cloud. Please provide an `Enso_File` as destination.")

Data_Link_Setup.Unavailable cause ->
Error.throw (Illegal_Argument.Error "Cannot save connection as Data Link: "+cause)

## PRIVATE
save_credentials_for_data_link data_link_location:Enso_File credentials:Credentials -> JS_Object =
# A plain text is automatically promoted to a secret.
secret_password = save_password_for_data_link data_link_location credentials.password

# But we keep the username as-is - if it was in plain text, it will stay in plain text.
JS_Object.from_pairs [["username", secure_value_to_json credentials.username], ["password", secure_value_to_json secret_password]]

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Standard.Base.Metadata.Widget.Text_Input

import Standard.Database.Connection.Connection_Options.Connection_Options
import Standard.Database.Connection.Credentials.Credentials
import Standard.Database.Internal.Data_Link_Setup.Data_Link_Setup

import project.SQLServer_Connection.SQLServer_Connection

Expand All @@ -33,13 +34,15 @@ type SQLServer_Details

Arguments:
- options: Overrides for the connection properties.
connect : Connection_Options -> SQLServer_Connection
connect self options =
connect : Connection_Options -> Boolean -> SQLServer_Connection
connect self options (allow_data_links : Boolean = True) =
data_link_setup = if allow_data_links then Data_Link_Setup.Available (create_data_link_structure self) else
Data_Link_Setup.already_a_data_link
properties = options.merge self.jdbc_properties
make_new database =
SQLServer_Details.SQLServer self.host self.credentials self.port (database.if_nothing self.database) . connect options

SQLServer_Connection.create self.jdbc_url properties make_new
SQLServer_Connection.create self.jdbc_url properties make_new data_link_setup

## PRIVATE
Provides the jdbc url for the connection.
Expand All @@ -55,3 +58,12 @@ type SQLServer_Details
database = [Pair.new 'databaseName' self.database]
credentials = [Pair.new 'user' self.credentials.username, Pair.new 'password' self.credentials.password]
account + database + credentials

## PRIVATE
private create_data_link_structure details:SQLServer_Details data_link_location:Enso_File -> JS_Object =
credentials_json = Data_Link_Setup.save_credentials_for_data_link data_link_location details.credentials
JS_Object.from_pairs <|
header = [["type", "SQLServer_Connection"], ["libraryName", "Standard.Microsoft"]]
connection_part = [["host", details.host], ["port", details.port], ["database_name", details.database]]
credential_part = [["credentials", credentials_json]]
header + connection_part + credential_part
Loading
Loading