Skip to content
Go back

解决Spring Data LDAP中Projection查询@Attribute字段返回空值的问题

Edit page

解决Spring Data LDAP中Projection查询@Attribute字段返回空值的问题

Spring Data LDAP是Spring Data项目的子项目,提供了面向LDAP存储库的Spring Data规范实现。
它让开发者可以像使用Spring Data JPA一样操作LDAP,支持Entity、Repository和Projection等功能。

问题出现在使用Projection投影查询实体的特定字段时:当实体字段使用@Attribute注解进行字段映射时,查询结果中相关字段值为空。

问题描述

以下场景演示了该问题:

Ldap数据库内容

# base dn
dn: dc=fastonetech,dc=com
objectClass: dcObject
objectClass:  organization
objectClass: top
dc: fastonetech
o: fastonetech

# ou => groups
dn: ou=groups,dc=fastonetech,dc=com
objectClass: organizationalUnit
objectClass: top
ou: groups

# group {tom}
dn: cn=tom,ou=groups,dc=fastonetech,dc=com
objectClass: top
objectClass: posixGroup
cn: tom
gidNumber: 1001
memberUid: tom

# group {tim}
dn: cn=tim,ou=groups,dc=fastonetech,dc=com
objectClass: top
objectClass: posixGroup
cn: tim
gidNumber: 1002
memberUid: tim

# group {joy}
dn: cn=joy,ou=groups,dc=fastonetech,dc=com
objectClass: top
objectClass: posixGroup
cn: joy
gidNumber: 1003
memberUid: joy

实体类定义

@Data
@Entry(base = "ou=groups", objectClasses = {"posixGroup", "top"})
public final class LdapGroup {

	@Id
	private Name dn;

	@Attribute(name = "cn")
	@DnAttribute(value = "cn", index = 1)
	private String name;

	@Attribute(name = "gidNumber")
	private Integer groupId;

	@Attribute(name = "memberUid")
	private Set<String> members = new HashSet<>();

	public void addMember(String member) {
		members.add(member);
	}

	public boolean isOwnerGroup() {
		return members.contains(this.name) && this.members.size() == 1;
	}

	public boolean isEmpty() {
		return members.isEmpty();
	}

	public void removeMember(String member) {
		members.remove(member);
	}
}

Projection接口定义

定义两个Projection接口进行测试,都只查询groupId字段:

interface LdapGroupRepositoryClassBasedProjection extends LdapRepository<LdapGroup> {

 record LdapGroupOnlyGroupId(Integer groupId) {

 }

 List<LdapGroupRepositoryClassBasedProjection.LdapGroupOnlyGroupId> findByGroupIdIsNotNull();

}

interface LdapGroupRepositoryInterfaceBasedProjection extends LdapRepository<LdapGroup> {

 interface LdapGroupOnlyGroupId {

  Integer getGroupId();

 }

 List<LdapGroupRepositoryInterfaceBasedProjection.LdapGroupOnlyGroupId> findByGroupIdIsNotNull();

}

测试类

测试代码期望通过Projection查询到三个groupId值(1001、1002、1003),但实际返回0个结果:

class Test {

	@Test
	void testLdapProjectionInterfaces() {
		assertThat(ldapGroupRepositoryClassBasedProjection.count()).isEqualTo(3);
		var classBasedProjection = ldapGroupRepositoryClassBasedProjection.findByGroupIdIsNotNull();
		var classBasedProjectionGroupIds = classBasedProjection.stream().map(LdapGroupRepositoryClassBasedProjection.LdapGroupOnlyGroupId::groupId).filter(Objects::nonNull).collect(Collectors.toSet());
		assertThat(classBasedProjectionGroupIds).hasSize(3); // 预期 3,实际0


		assertThat(ldapGroupRepositoryInterfaceBasedProjection.count()).isEqualTo(3);
		var interfaceProjection = ldapGroupRepositoryInterfaceBasedProjection.findByGroupIdIsNotNull();
		var interfaceProjectionGroupIds = interfaceProjection.stream().map(LdapGroupRepositoryInterfaceBasedProjection.LdapGroupOnlyGroupId::getGroupId).filter(Objects::nonNull).collect(Collectors.toSet());
		assertThat(interfaceProjectionGroupIds).hasSize(3); // 预期 3,实际0
	}

}

原因分析

通过断点调试Spring Data LDAP的查询过程来分析问题原因。在Spring Data LDAP中,所有与LDAP服务器的交互都通过LdapTemplate完成,因此在org.springframework.ldap.core.LdapTemplate类的search方法上设置断点:

public <T> List<T> search(Name base, String filter, SearchControls controls,
    ContextMapper<T> mapper, DirContextProcessor processor)

a667049d7ea12a3153a1572ed5f6043c580f30cdf1104352b4303f3ebbdda3ba.png

从调试信息可以看到controls.attributes值为["groupId"],这是问题所在:groupId在LDAP中对应的字段名应该是gidNumber

    /**
     *  Contains the list of attributes to be returned in
     * {@code SearchResult} for each matching entry of search. {@code null}
     * indicates that all attributes are to be returned.
     * @serial
     */
    private String[] attributesToReturn;

attributesToReturn字段用于控制LDAP返回哪些字段,问题在于上层传递该值时没有进行字段映射转换。

跟踪该值的传递流程,定位到LdapTemplate.searchControlsForQuery方法中的query.attributes()

public class LdapTemplate implements LdapOperations, InitializingBean {

    private SearchControls searchControlsForQuery(LdapQuery query, boolean returnObjFlag) {
		SearchControls searchControls = getDefaultSearchControls(this.defaultSearchScope, returnObjFlag,
				query.attributes());

		if (query.searchScope() != null) {
			searchControls.setSearchScope(query.searchScope().getId());
		}

		if (query.countLimit() != null) {
			searchControls.setCountLimit(query.countLimit());
		}

		if (query.timeLimit() != null) {
			searchControls.setTimeLimit(query.timeLimit());
		}
		return searchControls;
	}

}

query.attributes()中的数据在LdapQueryCreator.create方法中创建:

class LdapQueryCreator extends AbstractQueryCreator<LdapQuery, ContainerCriteria> {

	@Override
	protected ContainerCriteria create(Part part, Iterator<Object> iterator) {

		Entry entry = AnnotatedElementUtils.findMergedAnnotation(entityType, Entry.class);

		LdapQueryBuilder query = query();

		if (entry != null) {
			query = query.base(entry.base());
		}

		if (!inputProperties.isEmpty()) {
			query.attributes(inputProperties.toArray(new String[0]));
		}

		ConditionCriteria criteria = query.where(getAttribute(part));

		return appendCondition(part, iterator, criteria);
	}

}

值来自inputProperties,继续追踪发现它在PartTreeLdapRepositoryQuery.createQuery中设置:

public class PartTreeLdapRepositoryQuery extends AbstractLdapRepositoryQuery {

	private final PartTree partTree;
	private final ObjectDirectoryMapper objectDirectoryMapper;

	public PartTreeLdapRepositoryQuery(LdapQueryMethod queryMethod, Class<?> entityType, LdapOperations ldapOperations,
			MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext,
			EntityInstantiators instantiators) {

		super(queryMethod, entityType, ldapOperations, mappingContext, instantiators);

		partTree = new PartTree(queryMethod.getName(), entityType);
		objectDirectoryMapper = ldapOperations.getObjectDirectoryMapper();
	}

	@Override
	protected LdapQuery createQuery(LdapParameterAccessor parameters) {

		List<String> inputProperties = Collections.emptyList();
		ReturnedType returnedType = getQueryMethod().getResultProcessor().withDynamicProjection(parameters)
				.getReturnedType();

		if (returnedType.needsCustomConstruction()) {
			inputProperties = returnedType.getInputProperties();
		}

		org.springframework.data.ldap.repository.query.LdapQueryCreator queryCreator = new LdapQueryCreator(partTree,
				getEntityClass(), objectDirectoryMapper, parameters, inputProperties);
		return queryCreator.createQuery();
	}
}

关键代码段:

if (returnedType.needsCustomConstruction()) {
    inputProperties = returnedType.getInputProperties();
}

在Projection场景下,needsCustomConstruction()返回truereturnedType.getInputProperties()返回Projection声明的字段名。

问题根源在于Spring Data LDAP没有对Projection字段进行实体到LDAP的字段映射。我们的映射定义:

@Attribute(name = "gidNumber")
private Integer groupId;

解决方案

解决方案是在LdapQueryCreator.create方法中添加字段映射处理。该类的getAttribute方法已经处理其他场景的映射:

private String getAttribute(Part part) {
    PropertyPath path = part.getProperty();
        if (path.hasNext()) {
        throw new IllegalArgumentException("Nested properties are not supported");
    }

    return mapper.attributeFor(entityType, path.getSegment());
}

通过mapper.attributeFor可以获取实体字段映射后的LDAP字段名。修改create方法来支持映射:

@Override
protected ContainerCriteria create(Part part, Iterator<Object> iterator) {

	Entry entry = AnnotatedElementUtils.findMergedAnnotation(entityType, Entry.class);

	LdapQueryBuilder query = query();

	if (entry != null) {
		query = query.base(entry.base());
	}

	if (!inputProperties.isEmpty()) {
		query.attributes(inputProperties.stream().map(prop -> mapper.attributeFor(entityType, prop)).toList().toArray(new String[0]));
	}

	ConditionCriteria criteria = query.where(getAttribute(part));

	return appendCondition(part, iterator, criteria);
}

完整的修改代码如下。要在本地项目中应用此修复,可以创建同名类覆盖Spring的原始实现:

/*
 * Copyright 2016-2025 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.springframework.data.ldap.repository.query;

import static org.springframework.ldap.query.LdapQueryBuilder.*;

import java.util.Iterator;
import java.util.List;

import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.data.domain.Sort;
import org.springframework.data.mapping.PropertyPath;
import org.springframework.data.repository.query.parser.AbstractQueryCreator;
import org.springframework.data.repository.query.parser.Part;
import org.springframework.data.repository.query.parser.PartTree;
import org.springframework.ldap.odm.annotations.Entry;
import org.springframework.ldap.odm.core.ObjectDirectoryMapper;
import org.springframework.ldap.query.ConditionCriteria;
import org.springframework.ldap.query.ContainerCriteria;
import org.springframework.ldap.query.LdapQuery;
import org.springframework.ldap.query.LdapQueryBuilder;
import org.springframework.util.Assert;

/**
 * Creator of dynamic queries based on method names.
 *
 * @author Mattias Hellborg Arthursson
 * @author Mark Paluch
 */
class LdapQueryCreator extends AbstractQueryCreator<LdapQuery, ContainerCriteria> {

	private final Class<?> entityType;
	private final ObjectDirectoryMapper mapper;
	private final List<String> inputProperties;

	/**
	 * Constructs a new {@link LdapQueryCreator}.
	 *
	 * @param tree must not be {@literal null}.
	 * @param entityType must not be {@literal null}.
	 * @param mapper must not be {@literal null}.
	 * @param parameterAccessor must not be {@literal null}.
	 * @param inputProperties must not be {@literal null}.
	 */
	LdapQueryCreator(PartTree tree, Class<?> entityType, ObjectDirectoryMapper mapper,
			LdapParameterAccessor parameterAccessor, List<String> inputProperties) {

		super(tree, parameterAccessor);

		Assert.notNull(entityType, "Entity type must not be null");
		Assert.notNull(mapper, "ObjectDirectoryMapper must not be null");

		this.entityType = entityType;
		this.mapper = mapper;
		this.inputProperties = inputProperties;
	}

	@Override
	protected ContainerCriteria create(Part part, Iterator<Object> iterator) {

		Entry entry = AnnotatedElementUtils.findMergedAnnotation(entityType, Entry.class);

		LdapQueryBuilder query = query();

		if (entry != null) {
			query = query.base(entry.base());
		}

		if (!inputProperties.isEmpty()) {
			query.attributes(inputProperties.stream().map(prop -> mapper.attributeFor(entityType, prop)).toList().toArray(new String[0]));
		}

		ConditionCriteria criteria = query.where(getAttribute(part));

		return appendCondition(part, iterator, criteria);
	}

	private ContainerCriteria appendCondition(Part part, Iterator<Object> iterator, ConditionCriteria criteria) {

		Part.Type type = part.getType();

		String value = null;
		if (iterator.hasNext()) {
			Object next = iterator.next();
			value = next != null ? next.toString() : null;
		}
		switch (type) {
			case NEGATING_SIMPLE_PROPERTY:
				return criteria.not().is(value);
			case SIMPLE_PROPERTY:
				return criteria.is(value);
			case STARTING_WITH:
				return criteria.like(value + "*");
			case ENDING_WITH:
				return criteria.like("*" + value);
			case CONTAINING:
				return criteria.like("*" + value + "*");
			case LIKE:
				return criteria.like(value);
			case NOT_LIKE:
				return criteria.not().like(value);
			case GREATER_THAN_EQUAL:
				return criteria.gte(value);
			case LESS_THAN_EQUAL:
				return criteria.lte(value);
			case IS_NOT_NULL:
				return criteria.isPresent();
			case IS_NULL:
				return criteria.not().isPresent();
			default:
				throw new IllegalArgumentException(String.format("%s queries are not supported for LDAP repositories", type));
		}

	}

	private String getAttribute(Part part) {
		PropertyPath path = part.getProperty();
		if (path.hasNext()) {
			throw new IllegalArgumentException("Nested properties are not supported");
		}

		return mapper.attributeFor(entityType, path.getSegment());
	}

	@Override
	protected ContainerCriteria and(Part part, ContainerCriteria base, Iterator<Object> iterator) {
		ConditionCriteria criteria = base.and(getAttribute(part));

		return appendCondition(part, iterator, criteria);
	}

	@Override
	protected ContainerCriteria or(ContainerCriteria base, ContainerCriteria criteria) {
		return base.or(criteria);
	}

	@Override
	protected LdapQuery complete(ContainerCriteria criteria, Sort sort) {
		return criteria;
	}
}

参考资料


Edit page
Share this post on:

Previous Post
ArchLinux下NetworkManager导入OpenVPN及Mihomo分流配置
Next Post
解决Apache Guacamole中SSH连接数量超过60个以后无法建立新连接的问题