Repository navigation
.Net MEVD: Escaping and quoting #11676
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
Changes from all commits
39dc47c
6bcce23
d05754d
28ecd0f
f21416e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,7 +33,7 @@ ReadOnlyMemory<float> floatMemory | |
|
|
||
| public static object? GetPropertyValue(NpgsqlDataReader reader, string propertyName, Type propertyType) | ||
| { | ||
| int propertyIndex = reader.GetOrdinal(propertyName); | ||
| int propertyIndex = reader.GetOrdinal(PostgresConstants.Unescape(propertyName)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you sure unescaping is actually needed here (and in fact, is correct)? Escaping should only be needed when the column name is integrated in SQL (i.e. in a larger string containing a query), but this is an API specifically accepting the column name (not a SQL string). I don't think this is right (in fact, if the coiumn actually contains a double-column, this would cause a failure). Regardless, for future improvement, a better approach here would simply to read back columns from NpgsqlDataReader based on their position, instead of based on their name - we're the ones generating the SQL that selects the columns, so we should know the order in which they were sent and can read it back... This is a bit more efficient and cleaner, etc.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is needed, when I revert the change, the tests start to fail: In general, I've extended the
I agree, but it would require more non-trivial changes (build a dictionary name-position for every query), as the order may vary (we don't always include vector fields etc). If that is not a problem, I would prefer not to do it in this PR.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I see, that's because you've changed model building so that PropertyModel.StorageName is escaped - so now it has to be un-escaped (code): property.StorageName = this.Options.EscapeIdentifier is not null
? this.Options.EscapeIdentifier(storageName)
: storageName;I'm not sure I'm a fan of the approach... I think the model storage name itself shouldn't be escaped, and then we apply escaping to it when needed, and don't apply when not needed (like here, when interacting with DbDataReader). I guess you're trying to defensively ensure that identifiers are escaped by default (in case we forget to do it somewhere); but remember that this isn't a security problem - both property names and the names of captured variables are part of the application code, and so are trusted (any security concerns are around the contents of captured variables, since that's what typically comes from untrusted user inputs). So if we forget to escape an identifier somewhere, that's just a bug we should fix, and since we have some contexts where we want unescaped names (DbDataReader.GetOrdinal(), possibly logging....), I think the model should probably just contain the raw, unescaped name. In any case, I agree that reading back columns based on their position is something that belong out of this PR and can be done later.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One more thought on this... IIUC the property's StorageName is escaped, but it isn't quoted... That means that wherever the name needs to be integrated in SQL, we still need to remember to quote it - which I think removes the "safety-by-default" that having the escaped name in the model was supposed to provide in the first place... To go all the way with that approach, we'd have the model storage name be both escaped and quoted; we'd then need to unquote and unescape for certain contexts (DbDataReader.GetOrdinal(), combining table/column names into an index name). Or we go the other way, and just keep the raw, unquoted and unescaped name in the model, and simply call DelimitIdentifier wherever needed (again, we currently need to do this anyway for quoting). Building the index name would just be |
||
|
|
||
| if (reader.IsDBNull(propertyIndex)) | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,7 +109,7 @@ public virtual Task<UpdateResult> CreatePayloadIndexAsync( | |
| string fieldName, | ||
| PayloadSchemaType schemaType = PayloadSchemaType.Keyword, | ||
| CancellationToken cancellationToken = default) | ||
| => this._qdrantClient.CreatePayloadIndexAsync(collectionName, fieldName, schemaType, cancellationToken: cancellationToken); | ||
| => this._qdrantClient.CreatePayloadIndexAsync(collectionName, $"\"{fieldName}\"", schemaType, cancellationToken: cancellationToken); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Shay Rojansky (@roji) I went through the non-SQL connectors (except of cosmos) and this was the only line of code I needed to change to get the tests passing. We are usually using strongly typed .NET APIs for these and/or JSON serializer that does all the escaping for us. |
||
|
|
||
| /// <summary> | ||
| /// Drop a collection and all its associated data. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,7 @@ internal sealed class SqliteFilterTranslator : SqlFilterTranslator | |
| private readonly Dictionary<string, object> _parameters = new(); | ||
|
|
||
| internal SqliteFilterTranslator(VectorStoreRecordModel model, LambdaExpression lambdaExpression) | ||
| : base(model, lambdaExpression, sql: null) | ||
| : base(model, lambdaExpression, startParamIndex: 0, sql: null) | ||
| { | ||
| } | ||
|
|
||
|
|
@@ -61,20 +61,10 @@ protected override void TranslateQueryParameter(string name, object? value) | |
| } | ||
| else | ||
| { | ||
| // Duplicate parameter name, create a new parameter with a different name | ||
| // TODO: Share the same parameter when it references the same captured value | ||
| if (this._parameters.ContainsKey(name)) | ||
| { | ||
| var baseName = name; | ||
| var i = 0; | ||
| do | ||
| { | ||
| name = baseName + (i++); | ||
| } while (this._parameters.ContainsKey(name)); | ||
| } | ||
|
|
||
| this._parameters.Add(name, value); | ||
| this._sql.Append('@').Append(name); | ||
| string paramName = $"@{this._parameterIndex++}"; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm still not sure why we're always removing the name... It's really useful for reading the SQL, 99% of the cases aren't going to be problematic, and the 1% (where there are weird chars) can be handled separately, no? |
||
| this._parameters.Add(paramName, value); | ||
| this._sql.Append(paramName); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,8 +21,6 @@ internal static class SqliteVectorStoreCollectionCommandBuilder | |||||||||||
| { | ||||||||||||
| internal const string DistancePropertyName = "distance"; | ||||||||||||
|
|
||||||||||||
| internal static string EscapeIdentifier(this string value) => value.Replace("'", "''").Replace("\"", "\"\""); | ||||||||||||
|
|
||||||||||||
| public static DbCommand BuildTableCountCommand(SqliteConnection connection, string tableName) | ||||||||||||
| { | ||||||||||||
| Verify.NotNullOrWhiteSpace(tableName); | ||||||||||||
|
|
@@ -114,8 +112,6 @@ public static DbCommand BuildInsertCommand( | |||||||||||
|
|
||||||||||||
| for (var recordIndex = 0; recordIndex < records.Count; recordIndex++) | ||||||||||||
| { | ||||||||||||
| var rowIdentifierParameterName = GetParameterName(rowIdentifier, recordIndex); | ||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it was not being used |
||||||||||||
|
|
||||||||||||
| var (columns, parameters, values) = GetQueryParts( | ||||||||||||
| properties, | ||||||||||||
| records[recordIndex], | ||||||||||||
|
|
@@ -410,7 +406,23 @@ private static (List<string> Columns, List<string> ParameterNames, List<object?> | |||||||||||
|
|
||||||||||||
| private static string GetParameterName(string propertyName, int index) | ||||||||||||
| { | ||||||||||||
| return $"@{propertyName}{index}"; | ||||||||||||
| StringBuilder builder = new(); | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was there a bug here that this fixes? Or is this about slightly better perf?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The input value ( Lines 395 to 399 in 6bcce23
The tests I've provided cover it (when I revert this change, they fail)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this is the same problem discussed above for parameter names inside the filter translator (i.e. TranslateQueryParameter). For one thing, whatever logic we choose, we should probably not have two different implementations for handling parameter names (once in the filter translator, one here). BTW if I'm very pedantic, just appending an index at the end isn't a 100% solution, since two parameters could still conflict (imagine having one parameter But I don't want us to get too bogged down in this. I'm also OK with merging for now and then revisiting later. |
||||||||||||
| // In SQLite, parameter names must follow these rules: | ||||||||||||
| // 1. They must start with a prefix like '@', ':' or '$'. | ||||||||||||
| builder.Append('@'); | ||||||||||||
| // 2. After the prefix, the parameter name can include alphanumeric characters (a-z, A-Z, 0-9) and underscores (_) | ||||||||||||
| foreach (char character in propertyName) | ||||||||||||
| { | ||||||||||||
| if ((character is >= 'a' and <= 'z') || (character is >= 'A' and <= 'Z') | ||||||||||||
| || (character is >= '0' and <= '9') | ||||||||||||
| || character is '_') | ||||||||||||
| { | ||||||||||||
| builder.Append(character); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| // 3. Each parameter name in a query must be unique. | ||||||||||||
| builder.Append(index); | ||||||||||||
| return builder.ToString(); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| #endregion | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -90,8 +90,8 @@ public SqliteVectorStoreRecordCollection( | |||||||
| this._vectorSearchExtensionName = this._options.VectorSearchExtensionName ?? SqliteConstants.VectorSearchExtensionName; | ||||||||
|
|
||||||||
| // Escape both table names before exposing them to anything that may build SQL commands. | ||||||||
| this._dataTableName = name.EscapeIdentifier(); | ||||||||
| this._vectorTableName = GetVectorTableName(name, this._options).EscapeIdentifier(); | ||||||||
| this._dataTableName = SqliteConstants.Escape(name); | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: we can use
Suggested change
BTW it's slightly odd for me that we're caching the escaped names (this code isn't that high-perf). But if we do want to do that, may as well also add the quotes around it? In general, rather than treating escaping and quoting as two different concerns, we can have just a single API that accepts a table or column name and outputs the escaped, quoted string representation (in EF we call this
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The goal I had here was to ensure nobody is using unescaped values anywhere (doing it once was just perf side effect).
This is a great idea, however I am afraid we need both quoted and not:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For this, we can interpolate the completely raw (unquoted and unescaped) table and column names, and then run Delimit on the result. This way, we always start from raw identifiers and perform both escaping and quoting at the very end, after concatenation etc.
Ah, that is very shitty indeed... But it's a very special exception (that will hopefully be fixed), so I wouldn't design our code around this one flaw (we can specifically do escaping without quoting at that one place for now).
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
OK I think I missed this before. See https://github.com/microsoft/semantic-kernel/pull/11676/files#r2064348530 for some thoughts on this. |
||||||||
| this._vectorTableName = SqliteConstants.Escape(GetVectorTableName(name, this._options)); | ||||||||
|
|
||||||||
| this._model = new VectorStoreRecordModelBuilder(SqliteConstants.ModelBuildingOptions) | ||||||||
| .Build(typeof(TRecord), this._options.VectorStoreRecordDefinition, this._options.EmbeddingGenerator); | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,40 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using Microsoft.Extensions.VectorData; | ||
| using VectorDataSpecificationTests.Models; | ||
| using VectorDataSpecificationTests.Support; | ||
|
|
||
| namespace RedisIntegrationTests.Support; | ||
|
|
||
| public class RedisHashSetSimpleModelFixture : SimpleModelFixture<string> | ||
| { | ||
| public override TestStore TestStore => RedisTestStore.HashSetInstance; | ||
|
|
||
| public override VectorStoreRecordDefinition GetRecordDefinition() | ||
| => new() | ||
| { | ||
| Properties = | ||
| [ | ||
| new VectorStoreRecordKeyProperty(nameof(SimpleRecord<string>.Id), typeof(string)) | ||
| { | ||
| StoragePropertyName = "i d" // intentionally with a space | ||
| }, | ||
| new VectorStoreRecordVectorProperty(nameof(SimpleRecord<string>.Floats), typeof(ReadOnlyMemory<float>?), SimpleRecord<string>.DimensionCount) | ||
| { | ||
| DistanceFunction = this.DistanceFunction, | ||
| IndexKind = this.IndexKind, | ||
| // Redis HashSet does not support escaping column names used in the Query (Json does). | ||
| }, | ||
| new VectorStoreRecordDataProperty(nameof(SimpleRecord<string>.Number), typeof(int)) | ||
| { | ||
| IsIndexed = true, | ||
| StoragePropertyName = "num'b\"er" // intentionally with both quotes | ||
| }, | ||
| new VectorStoreRecordDataProperty(nameof(SimpleRecord<string>.Text), typeof(string)) | ||
| { | ||
| IsIndexed = true, | ||
| StoragePropertyName = "t e]xt" // intentionally with a space and ] | ||
| }, | ||
| ] | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using Microsoft.Extensions.VectorData; | ||
| using VectorDataSpecificationTests.Models; | ||
| using VectorDataSpecificationTests.Support; | ||
|
|
||
| namespace SqliteIntegrationTests.Support; | ||
|
|
@@ -8,4 +10,30 @@ public class SqliteSimpleModelFixture<TKey> : SimpleModelFixture<TKey> | |
| where TKey : notnull | ||
| { | ||
| public override TestStore TestStore => SqliteTestStore.Instance; | ||
|
|
||
| public override VectorStoreRecordDefinition GetRecordDefinition() | ||
| => new() | ||
| { | ||
| Properties = | ||
| [ | ||
| // The Id and Floats properties are stored in the vec_ table and this extension currently does not support | ||
| // quoting column names, so don't customize their storage property names. | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The vec extension simply does not support quoting. Example: CREATE VIRTUAL TABLE "vec_6a64a46d-6f95-4c8b-a8ad-4c4b009f0a68" USING vec0(
"id" TEXT PRIMARY KEY,
"embedding" FLOAT[3] distance_metric=cosine
); |
||
| new VectorStoreRecordKeyProperty(nameof(SimpleRecord<TKey>.Id), typeof(TKey)), | ||
| new VectorStoreRecordVectorProperty(nameof(SimpleRecord<TKey>.Floats), typeof(ReadOnlyMemory<float>?), SimpleRecord<TKey>.DimensionCount) | ||
| { | ||
| DistanceFunction = this.DistanceFunction, | ||
| IndexKind = this.IndexKind, | ||
| }, | ||
| new VectorStoreRecordDataProperty(nameof(SimpleRecord<TKey>.Number), typeof(int)) | ||
| { | ||
| IsIndexed = true, | ||
| StoragePropertyName = "num'b\"er" // intentionally with both quotes | ||
| }, | ||
| new VectorStoreRecordDataProperty(nameof(SimpleRecord<TKey>.Text), typeof(string)) | ||
| { | ||
| IsIndexed = true, | ||
| StoragePropertyName = "t e]xt" // intentionally with a space and ] | ||
| }, | ||
| ] | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've changed the argument from
stringtoVectorStoreRecordPropertyModeljust to make sure all the derived types deal withStorageNamein explicit way (they know this value is always escaped, while some random string may or may not be escaped).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea.